diff --git a/backend/src/apis/app_api/admin/costs/models.py b/backend/src/apis/app_api/admin/costs/models.py
index 7d3da566..98084046 100644
--- a/backend/src/apis/app_api/admin/costs/models.py
+++ b/backend/src/apis/app_api/admin/costs/models.py
@@ -499,6 +499,12 @@ class FeedbackProfile(BaseModel):
down: int = 0
by_turn_class: Optional[FeedbackByTurnClass] = Field(None, alias="byTurnClass")
unjoined: int = 0
+ # Down-thumbs the user followed with a retry-with-correction, and what
+ # that rework cost: the thumbed call(s) plus the retry turn's calls
+ # (response-feedback spec §7 "rework cost"). ``None`` when no retry has
+ # a cost row to price.
+ retried: int = 0
+ rework_usd: Optional[float] = Field(None, alias="reworkUsd")
class DataCoverage(BaseModel):
diff --git a/backend/src/apis/app_api/admin/costs/service.py b/backend/src/apis/app_api/admin/costs/service.py
index bf4a5eb6..2fee2597 100644
--- a/backend/src/apis/app_api/admin/costs/service.py
+++ b/backend/src/apis/app_api/admin/costs/service.py
@@ -126,6 +126,13 @@ def _join_feedback(
any_turn_class = any(_turn_class(r) is not None for r in records)
buckets = FeedbackByTurnClass() if any_turn_class else None
profile = FeedbackProfile()
+ # Every cost row per assistant message index, for pricing rework.
+ cost_by_message: Dict[int, float] = {}
+ for record in records:
+ message_id = _as_int(record.get("messageId"))
+ if message_id is not None:
+ cost_by_message[message_id] = cost_by_message.get(message_id, 0.0) + (_record_cost(record) or 0.0)
+ rework_total: Optional[float] = None
for row in feedback_rows:
# Explicit thumbs only — implicit signals (spec §10) share the row
# family but answer a different question and must never be summed in.
@@ -139,6 +146,12 @@ def _join_feedback(
else:
profile.down += 1
message_id = _as_int(row.get("messageId"))
+ retry_id = _as_int(row.get("retryMessageId"))
+ if value == -1 and retry_id is not None:
+ profile.retried += 1
+ rework = _rework_cost(cost_by_message, message_id, retry_id)
+ if rework is not None:
+ rework_total = (rework_total or 0.0) + rework
record = by_message.get(message_id) if message_id is not None else None
if record is None:
profile.unjoined += 1
@@ -156,9 +169,33 @@ def _join_feedback(
else:
bucket.down += 1
profile.by_turn_class = buckets
+ profile.rework_usd = round(rework_total, 6) if rework_total is not None else None
return profile
+def _rework_cost(
+ cost_by_message: Dict[int, float],
+ thumbed_message_id: Optional[int],
+ retry_message_id: int,
+) -> Optional[float]:
+ """Dollars spent on a down-thumbed answer plus its retry: the thumbed
+ message's call rows, plus the retry turn's assistant rows. The retry is
+ a *user* message (no cost row); its turn's assistant messages are the
+ consecutive indexes after it — a gap means the next user message. ``None``
+ when neither side has a cost row to price."""
+ found = False
+ total = 0.0
+ if thumbed_message_id is not None and thumbed_message_id in cost_by_message:
+ total += cost_by_message[thumbed_message_id]
+ found = True
+ index = retry_message_id + 1
+ while index in cost_by_message:
+ total += cost_by_message[index]
+ found = True
+ index += 1
+ return total if found else None
+
+
def _context_tokens(record: Dict[str, Any]) -> int:
"""True context occupancy of one call: uncached input + cached prefix +
newly cached tokens (Bedrock reports the three disjointly)."""
diff --git a/backend/src/apis/app_api/messages/models.py b/backend/src/apis/app_api/messages/models.py
index 95d4fc00..7d70d919 100644
--- a/backend/src/apis/app_api/messages/models.py
+++ b/backend/src/apis/app_api/messages/models.py
@@ -137,6 +137,10 @@ class MessageFeedback(BaseModel):
value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ retry_message_id: Optional[int] = Field(
+ None, alias="retryMessageId", ge=0,
+ description="Index of the user message sent as a retry-with-correction after this thumb (content-free link)",
+ )
updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")
@@ -147,6 +151,10 @@ class MessageFeedbackRequest(BaseModel):
value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ retry_message_id: Optional[int] = Field(
+ None, alias="retryMessageId", ge=0,
+ description="Set when the user sent a retry-with-correction: that user message's index",
+ )
class MessageMetadata(BaseModel):
diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py
index 5d069c9b..a2d0a57a 100644
--- a/backend/src/apis/app_api/sessions/routes.py
+++ b/backend/src/apis/app_api/sessions/routes.py
@@ -694,7 +694,9 @@ async def put_message_feedback_endpoint(
``message_id`` is the message's 0-based index in the conversation — the
trailing number of the SPA's ``msg-{sessionId}-{index}`` id, and the
- ``messageId`` the message's cost row carries.
+ ``messageId`` the message's cost row carries. ``retryMessageId`` in the
+ body links the user message sent as a retry-with-correction; it is kept
+ across later thumbs on the same message.
"""
_require_message_feedback()
try:
@@ -704,6 +706,7 @@ async def put_message_feedback_endpoint(
message_id=message_id,
value=body.value,
reason=body.reason,
+ retry_message_id=body.retry_message_id,
)
except SessionNotOwned:
raise HTTPException(status_code=404, detail=f"Session not found: {session_id}")
diff --git a/backend/src/apis/shared/observability/content_policy.py b/backend/src/apis/shared/observability/content_policy.py
index 69d83aa5..eb0ccde6 100644
--- a/backend/src/apis/shared/observability/content_policy.py
+++ b/backend/src/apis/shared/observability/content_policy.py
@@ -212,6 +212,7 @@ def is_content_bearing(path: str) -> bool:
"value",
"reason",
"signal",
+ "retryMessageId", # a message index, the retry-with-correction link
"updatedAt",
)
diff --git a/backend/src/apis/shared/sessions/feedback.py b/backend/src/apis/shared/sessions/feedback.py
index 1400b163..15ed464e 100644
--- a/backend/src/apis/shared/sessions/feedback.py
+++ b/backend/src/apis/shared/sessions/feedback.py
@@ -15,13 +15,19 @@
SK: F#{session_id}#{message_id}
GSI_PK: SESSION#{session_id} (SessionLookupIndex)
GSI_SK: F#{message_id}
- sessionId, messageId, userId, value, reason?, signal, updatedAt, ttl
+ sessionId, messageId, userId, value, reason?, signal, retryMessageId?, updatedAt, ttl
``signal`` is ``"explicit"`` for a thumb. ``docs/specs/response-feedback.md``
§10 adds implicit signals (copy, continue, edit-and-resend, abandonment) to
this same row family under ``signal: "implicit"``; every reader here filters
to explicit rows so that phase needs no backfill and the two are never summed.
+``retryMessageId`` is the index of the user message the SPA sent as a
+*retry with correction* after a down-thumb (response-feedback spec §7 "the
+retry loop", §11 PR-1's consequence). It is a link, never the correction's
+text: the profile counts retries and prices the rework from the cost rows
+the link points at. A later re-thumb without the field keeps the link.
+
``messageId`` is the same 0-based index the ``C#`` cost row carries for the
assistant message, so an admin read joins feedback to the call's turn class
(``hasDocuments`` / ``documentDigests`` / ``documentReads``, #1137) on
@@ -82,9 +88,11 @@ def _keys(user_id: str, session_id: str, message_id: int) -> Dict[str, str]:
def _to_model(item: Dict[str, Any]) -> MessageFeedback:
+ retry = item.get("retryMessageId")
return MessageFeedback(
value=int(item.get("value", 0)),
reason=item.get("reason") if item.get("reason") in FEEDBACK_REASONS else None,
+ retry_message_id=int(retry) if retry is not None else None,
updated_at=str(item.get("updatedAt", "")),
)
@@ -124,9 +132,16 @@ async def put_message_feedback(
message_id: int,
value: int,
reason: Optional[str] = None,
+ retry_message_id: Optional[int] = None,
) -> MessageFeedback:
"""Write (or replace) this user's thumb on one message.
+ An upsert (``update_item``): ``value`` / ``reason`` / ``updatedAt`` are
+ replaced on every call, ``retryMessageId`` is set when given and kept
+ otherwise, so a user who thumbs again after retrying does not lose the
+ link. ``ReturnValues=ALL_OLD`` tells us what a replace replaced, so the
+ session rollups move by the delta rather than double-counting.
+
Raises :class:`SessionNotOwned` when the session is not this user's, and
``ValueError`` on a value outside ``{1, -1}`` or a reason outside
:data:`FEEDBACK_REASONS` — the route's request model already rejects
@@ -136,35 +151,60 @@ async def put_message_feedback(
raise ValueError("feedback value must be 1 or -1")
if reason is not None and reason not in FEEDBACK_REASONS:
raise ValueError("feedback reason must be one of the fixed codes")
+ if retry_message_id is not None and (not isinstance(retry_message_id, int) or retry_message_id < 0):
+ raise ValueError("retryMessageId must be a non-negative message index")
if is_preview_session(session_id):
# Preview sessions persist nothing; echo the thumb so the UI is consistent.
- return MessageFeedback(value=value, reason=reason, updated_at=_now())
+ return MessageFeedback(value=value, reason=reason, retry_message_id=retry_message_id, updated_at=_now())
table = _table()
session_sk = await _owned_session_sk(session_id, user_id, table)
now = _now()
ttl = int((datetime.now(timezone.utc) + timedelta(days=FEEDBACK_TTL_DAYS)).timestamp())
- item: Dict[str, Any] = {
- **_keys(user_id, session_id, message_id),
+ sets = {
"GSI_PK": f"SESSION#{session_id}",
"GSI_SK": f"F#{message_id}",
"sessionId": session_id,
"messageId": int(message_id),
"userId": user_id,
- "value": int(value),
- "signal": "explicit",
+ "#value": int(value),
+ "#signal": "explicit",
"updatedAt": now,
- "ttl": ttl,
+ "#ttl": ttl,
}
if reason:
- item["reason"] = reason
-
- # ReturnValues=ALL_OLD tells us what a replace is replacing, so the
- # session rollups move by the delta rather than double-counting.
- response = table.put_item(Item=item, ReturnValues="ALL_OLD")
+ sets["reason"] = reason
+ if retry_message_id is not None:
+ sets["retryMessageId"] = int(retry_message_id)
+ names = {"#value": "value", "#signal": "signal", "#ttl": "ttl"}
+ values: Dict[str, Any] = {}
+ set_parts = []
+ for i, (attr, val) in enumerate(sets.items()):
+ placeholder = f":v{i}"
+ values[placeholder] = val
+ set_parts.append(f"{attr} = {placeholder}")
+ expression = "SET " + ", ".join(set_parts)
+ if not reason:
+ expression += " REMOVE reason"
+
+ response = table.update_item(
+ Key=_keys(user_id, session_id, message_id),
+ UpdateExpression=expression,
+ ExpressionAttributeNames=names,
+ ExpressionAttributeValues=values,
+ ReturnValues="ALL_OLD",
+ )
previous = response.get("Attributes") or {}
previous_value = int(previous.get("value", 0)) if previous else 0
+ item: Dict[str, Any] = {
+ "value": int(value),
+ "reason": reason,
+ "retryMessageId": (
+ int(retry_message_id) if retry_message_id is not None else previous.get("retryMessageId")
+ ),
+ "updatedAt": now,
+ }
up = (1 if value == 1 else 0) - (1 if previous_value == 1 else 0)
down = (1 if value == -1 else 0) - (1 if previous_value == -1 else 0)
diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py
index ae3c3d9d..20e35922 100644
--- a/backend/src/apis/shared/sessions/models.py
+++ b/backend/src/apis/shared/sessions/models.py
@@ -662,6 +662,10 @@ class MessageFeedback(BaseModel):
value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ retry_message_id: Optional[int] = Field(
+ None, alias="retryMessageId", ge=0,
+ description="Index of the user message sent as a retry-with-correction after this thumb (content-free link)",
+ )
updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")
@@ -672,6 +676,10 @@ class MessageFeedbackRequest(BaseModel):
value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ retry_message_id: Optional[int] = Field(
+ None, alias="retryMessageId", ge=0,
+ description="Set when the user sent a retry-with-correction: that user message's index",
+ )
class MessageMetadata(BaseModel):
diff --git a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
index db5c794b..74cc883a 100644
--- a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
+++ b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
@@ -108,7 +108,7 @@ def test_session_profile_returns_200():
"feedback": False,
"documents": False,
}
- assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0}
+ assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0, "retried": 0, "reworkUsd": None}
service.get_session_profile.assert_awaited_once_with("s1")
diff --git a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
index 530b8131..5a5b75be 100644
--- a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
+++ b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
@@ -333,6 +333,29 @@ async def test_no_feedback_rows_falls_back_to_rollups_and_coverage_is_honest():
assert p.feedback.by_turn_class is None
+@pytest.mark.asyncio
+async def test_retries_are_counted_and_rework_is_priced_from_the_cost_rows():
+ # Turn A: assistant 1 (thumbed down, $0.10). Retry sent as user message 2;
+ # its turn is assistant 3 + 4 ($0.20 + $0.05, a tool round trip). User 5,
+ # assistant 6 ($0.99) is the NEXT turn and must not be counted.
+ records = [_call(1, cost=0.10), _call(3, cost=0.20), _call(4, cost=0.05), _call(6, cost=0.99)]
+ feedback = [{**_feedback(1, -1, "wrong"), "retryMessageId": 2}, _feedback(6, 1)]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+ assert p.feedback.retried == 1
+ assert p.feedback.rework_usd == 0.35
+ assert (p.feedback.up, p.feedback.down) == (1, 1)
+
+ # A retry whose turn has no cost rows yet (still streaming) counts, but prices only the thumbed side.
+ feedback = [{**_feedback(1, -1, "wrong"), "retryMessageId": 7}]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+ assert p.feedback.retried == 1 and p.feedback.rework_usd == 0.10
+
+ # Nothing priced at all → None, not 0.
+ feedback = [{**_feedback(9, -1), "retryMessageId": 10}]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+ assert p.feedback.retried == 1 and p.feedback.rework_usd is None
+
+
@pytest.mark.asyncio
async def test_implicit_signal_rows_are_never_summed_into_the_thumb_counts():
records = [_call(0)]
diff --git a/backend/tests/apis/app_api/test_message_feedback_routes.py b/backend/tests/apis/app_api/test_message_feedback_routes.py
index f2b7ed39..605af26c 100644
--- a/backend/tests/apis/app_api/test_message_feedback_routes.py
+++ b/backend/tests/apis/app_api/test_message_feedback_routes.py
@@ -48,7 +48,16 @@ def test_put_stores_and_echoes_the_thumb(monkeypatch):
assert resp.status_code == 200
assert resp.json() == {"value": -1, "reason": "instructions", "updatedAt": "2026-09-16T00:00:00Z"}
- put.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, value=-1, reason="instructions")
+ put.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, value=-1, reason="instructions", retry_message_id=None)
+
+
+def test_put_forwards_the_retry_link(monkeypatch):
+ put = AsyncMock(return_value=MessageFeedback(value=-1, retry_message_id=4, updated_at="t"))
+ client = _client(monkeypatch, put=put)
+ resp = client.put("/sessions/s1/messages/3/feedback", json={"value": -1, "retryMessageId": 4})
+ assert resp.status_code == 200 and resp.json()["retryMessageId"] == 4
+ assert put.await_args.kwargs["retry_message_id"] == 4
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": -1, "retryMessageId": -1}).status_code == 422
def test_put_rejects_free_text_and_out_of_range_values(monkeypatch):
diff --git a/backend/tests/shared/test_message_feedback.py b/backend/tests/shared/test_message_feedback.py
index c4ea26c5..aae508dd 100644
--- a/backend/tests/shared/test_message_feedback.py
+++ b/backend/tests/shared/test_message_feedback.py
@@ -73,7 +73,7 @@ async def test_put_writes_one_row_keyed_beside_the_cost_row(table):
assert "ttl" in row
# Content-free: nothing on the row but ids, a number, a timestamp and keys.
assert row["signal"] == "explicit"
- assert set(row) <= {"PK", "SK", "GSI_PK", "GSI_SK", "sessionId", "messageId", "userId", "value", "signal", "updatedAt", "ttl"}
+ assert set(row) <= {"PK", "SK", "GSI_PK", "GSI_SK", "sessionId", "messageId", "userId", "value", "signal", "retryMessageId", "updatedAt", "ttl"}
@pytest.mark.asyncio
@@ -97,6 +97,28 @@ async def test_second_thumb_replaces_the_first_and_rollups_follow(table):
assert _feedback_items()[0]["reason"] == "wrong"
+@pytest.mark.asyncio
+async def test_retry_link_is_set_once_and_kept_across_later_thumbs(table):
+ """Retry-with-correction (response-feedback §7): the row links the user
+ message the correction was sent as, and a later re-thumb without the
+ field must not drop it."""
+ first = await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong")
+ assert first.retry_message_id is None
+ linked = await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong", retry_message_id=4)
+ assert linked.retry_message_id == 4
+ assert int(_feedback_items()[0]["retryMessageId"]) == 4
+
+ # Re-thumb (say, the retry was better and they flip to up): link stays.
+ again = await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ assert again.value == 1 and again.retry_message_id == 4
+ row = _feedback_items()[0]
+ assert int(row["retryMessageId"]) == 4 and "reason" not in row
+ assert _session_row()["thumbsUp"] == 1 and _session_row()["thumbsDown"] == 0
+
+ with pytest.raises(ValueError):
+ await fb.put_message_feedback(SESSION, OWNER, 3, -1, retry_message_id=-1)
+
+
@pytest.mark.asyncio
async def test_delete_removes_the_row_and_decrements(table):
await fb.put_message_feedback(SESSION, OWNER, 1, -1, reason="tool_failed")
diff --git a/docs/specs/response-feedback.md b/docs/specs/response-feedback.md
index b8aa0269..8c1ab116 100644
--- a/docs/specs/response-feedback.md
+++ b/docs/specs/response-feedback.md
@@ -1,9 +1,10 @@
# Response feedback
**Status:** PARTIALLY BUILT — capture and the read model shipped in PR #1142
-(2026-09-16, as document-context-offload PR-7); the consequence (§11 PR-1's
-retry-with-correction), implicit signals and eval sampling are not built.
-See §13. Written 2026-09-04 from the "how would we benefit?" conversation.
+(2026-09-16, as document-context-offload PR-7) and the consequence
+(retry-with-correction) in the PR stacked on it; §11 PR-1 is therefore
+complete. Implicit signals and eval sampling are not built. See §13. Written
+2026-09-04 from the "how would we benefit?" conversation.
**Refs:** `docs/specs/agentcore-evaluations-spike-findings.md` (the eval
harness this feeds), `docs/specs/mid-turn-steering.md` (the injection path
Phase 1 reuses), `docs/specs/agent-marketplace.md` D15 (the *other* feedback
@@ -353,8 +354,21 @@ points:
`agentSwitched`, skills) are further buckets in the same join loop.
- **Open question 4 settled**: preview sessions echo the thumb and persist
nothing, matching the `D#` write. Preview data never reaches aggregates.
-- **Not built**: retry-with-correction (§11 PR-1's consequence — open
- question 1 still stands), implicit signals (PR-2), eval sampling (PR-4),
+- **Retry-with-correction (the consequence), second PR.** Open question 1
+ settled: **a new turn, not `/steer`** — the steer path targets a *running*
+ turn through the lease row, and a finished turn is corrected by an
+ ordinary next message. The down-thumb reason row offers *Retry with that
+ in mind*, which prefills the composer (`ComposerDraftService`) with a
+ correction template for the reason code; the user edits and sends it as a
+ normal message, so it goes where messages go (AgentCore Memory) and never
+ touches the metadata table. When that message is added, the send path
+ links it to the thumb as `retryMessageId` on the `F#` row — an index,
+ never the text — and a later re-thumb keeps the link. This is open
+ question 2's answer for now: the pair is *recorded* without its content,
+ so Phase 6 can find it once the consent decision is made. The profile
+ reports `feedback.retried` and `reworkUsd` (§7 "rework cost": the thumbed
+ call rows plus the retry turn's consecutive assistant rows).
+- **Not built**: implicit signals (PR-2), eval sampling (PR-4),
author/marketplace surfaces (PR-5), the report-dialog escape hatch in the
reason row.
diff --git a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
index 104f5469..f58fc184 100644
--- a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
+++ b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
@@ -439,6 +439,9 @@ export interface FeedbackProfile {
down: number;
byTurnClass?: Record
{{ retries }}
+ } } @else {—
not tracked
@@ -838,6 +843,10 @@ export class SessionCostAnatomyPage { return feedback ? downRate(feedback) : null; }); + readonly feedbackRetryLine = computed(() => + this.profileResource.hasValue() ? feedbackRetryLine(this.profileResource.value().feedback) : null, + ); + readonly feedbackTurnClassLine = computed(() => this.profileResource.hasValue() ? feedbackByTurnClassLine(this.profileResource.value().feedback) : null, ); diff --git a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts index 9763d7ef..ec9ad88a 100644 --- a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts +++ b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts @@ -4,6 +4,7 @@ import { cleanCeiling, downRate, feedbackByTurnClassLine, + feedbackRetryLine, formatBytes, formatEvidenceValue, formatTokensShort, @@ -43,6 +44,12 @@ describe('session-profile.util', () => { expect(line).toBe('full 50% of 2 · digest 0% of 2 · no docs 100% of 1'); }); + it('feedbackRetryLine names retries and prices rework only when known', () => { + expect(feedbackRetryLine({ up: 0, down: 1 })).toBeNull(); + expect(feedbackRetryLine({ up: 0, down: 1, retried: 1 })).toBe('1 retried'); + expect(feedbackRetryLine({ up: 0, down: 2, retried: 2, reworkUsd: 0.351 })).toBe('2 retried · $0.35 rework'); + }); + it('feedbackByTurnClassLine is null when the turn class is not tracked', () => { expect(feedbackByTurnClassLine({ up: 1, down: 1, byTurnClass: null })).toBeNull(); expect(feedbackByTurnClassLine({ up: 1, down: 1 })).toBeNull(); diff --git a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts index 7463f493..eb7a63ff 100644 --- a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts +++ b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts @@ -232,6 +232,15 @@ export function downRate(counts: { up: number; down: number }): number | null { * (`full 50% of 4 · digest 0% of 2`). Classes with no thumbs are skipped; * null when the turn class is not tracked or nothing was thumbed. */ +/** `2 retried · $0.35 rework`, or null when nothing was retried. */ +export function feedbackRetryLine(feedback: FeedbackProfile | null | undefined): string | null { + const retried = feedback?.retried ?? 0; + if (retried === 0) return null; + const parts = [`${retried} retried`]; + if (feedback?.reworkUsd != null) parts.push(`$${feedback.reworkUsd.toFixed(2)} rework`); + return parts.join(' · '); +} + export function feedbackByTurnClassLine(feedback: FeedbackProfile | null | undefined): string | null { const by = feedback?.byTurnClass; if (!by) return null; diff --git a/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts index 0e9b5732..12bce0f3 100644 --- a/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/chat-input/chat-input.component.spec.ts @@ -10,6 +10,7 @@ import { ToastService } from '../../../services/toast/toast.service'; import { ToolService } from '../../../services/tool/tool.service'; import { VoiceChatService } from '../../services/voice'; import { SteeringService } from '../../services/chat/steering.service'; +import { ComposerDraftService } from '../../services/session/composer-draft.service'; import { ChatInputComponent } from './chat-input.component'; const AGENTS: MentionableAgent[] = [ @@ -1238,3 +1239,70 @@ describe('ChatInputComponent — rotating discovery hints', () => { expect(textarea.getAttribute('placeholder')).toContain('when this response finishes'); }); }); + +describe('ChatInputComponent — composer drafts (feedback retry-with-correction)', () => { + let fixture: ComponentFixture