From 7473b1b7bba95506ff902b284c0f02ed64abc900 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 22:33:43 -0700 Subject: [PATCH 1/6] Improve agent turn recovery and question cards --- apps/server/src/agents/manager.ts | 30 +++ .../src/agents/provider-session-state.ts | 177 ++++++++++++++++++ .../server/src/agents/tmux/command-builder.ts | 3 + .../test/provider-session-state.test.ts | 93 +++++++++ apps/server/test/tmux-command-builder.test.ts | 12 ++ .../src/components/app/chat/chat-entries.tsx | 17 +- .../components/app/chat/chat-feed.test.tsx | 25 ++- 7 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/agents/provider-session-state.ts create mode 100644 apps/server/test/provider-session-state.test.ts diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index cdc88739d..e6caf3f43 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -26,6 +26,7 @@ import { getActivePersonality } from "../db/personalities.js"; import { isTrimmedLaunchGuidanceEnabled } from "../launch-guidance-settings.js"; import { isChatSurfaceEnabled } from "../chat-surface-settings.js"; import { findCodexSessionId } from "./codex-sessions.js"; +import { inspectClaudeSessionState } from "./provider-session-state.js"; import { harvestTokenUsage } from "./token-harvester.js"; import { errorMessage } from "../shared/lib/error-message.js"; import { @@ -1203,6 +1204,34 @@ export class AgentManager { // Use a conditional UPDATE to avoid races from concurrent start requests. let cliSessionId = agent.cliSessionId; let shouldResume = !!cliSessionId; + let resumeRecoveryPrompt: string | undefined; + if (cliSessionId && agent.type === "claude") { + const effectiveCwd = agent.worktreePath ?? agent.cwd; + try { + const sessionState = await inspectClaudeSessionState( + effectiveCwd, + cliSessionId + ); + if (sessionState.state === "interrupted") { + resumeRecoveryPrompt = + "Dispatch detected that the previous provider turn ended without a clean completion marker. Resume the interrupted task now. First reconcile any in-flight tool state from the transcript; do not repeat completed operations, and do not merely restate a plan."; + this.logger.info( + { + agentId: id, + provider: "claude", + sessionState: sessionState.state, + reason: sessionState.reason, + }, + "Resuming interrupted provider session with recovery guidance" + ); + } + } catch (error) { + this.logger.debug( + { err: error, agentId: id, provider: "claude" }, + "Provider session inspection failed; preserving native resume" + ); + } + } if (!cliSessionId && agent.type === "claude") { cliSessionId = randomUUID(); cliSessionId = await this.claimCliSessionId(id, cliSessionId); @@ -1257,6 +1286,7 @@ export class AgentManager { autoReview: !agent.persona && (agent.autoReview ?? false), trimmedGuidance, chatSurface, + initialPrompt: resumeRecoveryPrompt, personalityPrompt: personality?.prompt ?? null, model: agent.model ?? undefined, } diff --git a/apps/server/src/agents/provider-session-state.ts b/apps/server/src/agents/provider-session-state.ts new file mode 100644 index 000000000..8b3b7e0d9 --- /dev/null +++ b/apps/server/src/agents/provider-session-state.ts @@ -0,0 +1,177 @@ +import { open, stat } from "node:fs/promises"; +import path from "node:path"; + +import { cwdToClaudeProjectDir } from "./token-harvester.js"; + +const MAX_TAIL_BYTES = 256 * 1024; +const SESSION_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; + +export type ProviderSessionState = + | { state: "complete"; reason: string } + | { state: "interrupted"; reason: string } + | { state: "unknown"; reason: string }; + +async function readJsonlTail( + filePath: string +): Promise[] | null> { + let before: Awaited>; + try { + before = await stat(filePath); + } catch { + return null; + } + if (!before.isFile() || before.size === 0) return []; + + const start = Math.max(0, before.size - MAX_TAIL_BYTES); + const length = before.size - start; + const handle = await open(filePath, "r"); + let bytesRead = 0; + let buffer: Buffer; + try { + buffer = Buffer.alloc(length); + ({ bytesRead } = await handle.read(buffer, 0, length, start)); + } finally { + await handle.close(); + } + + const after = await stat(filePath).catch(() => null); + if ( + !after || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs + ) { + return null; + } + + let text = buffer.subarray(0, bytesRead).toString("utf8"); + if (start > 0) { + const newline = text.indexOf("\n"); + if (newline < 0) return null; + text = text.slice(newline + 1); + } + const lines = text.split("\n"); + if (lines.at(-1)?.trim()) return null; + lines.pop(); + + const entries: Record[] = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return null; + entries.push(parsed as Record); + } catch { + return null; + } + } + return entries; +} + +function assistantStopReason(entry: Record): unknown { + if (entry.type !== "assistant") return undefined; + const message = entry.message; + if (!message || typeof message !== "object" || Array.isArray(message)) + return undefined; + return (message as Record).stop_reason; +} + +function isHumanUserEntry(entry: Record): boolean { + if (entry.type !== "user") return false; + const message = entry.message; + if (!message || typeof message !== "object" || Array.isArray(message)) + return false; + const content = (message as Record).content; + if (typeof content === "string") return true; + if (!Array.isArray(content)) return false; + return !content.every( + (part) => + !!part && + typeof part === "object" && + !Array.isArray(part) && + (part as Record).type === "tool_result" + ); +} + +function isToolResultEntry(entry: Record): boolean { + if (entry.type !== "user") return false; + const message = entry.message; + if (!message || typeof message !== "object" || Array.isArray(message)) + return false; + const content = (message as Record).content; + return ( + Array.isArray(content) && + content.some( + (part) => + !!part && + typeof part === "object" && + !Array.isArray(part) && + (part as Record).type === "tool_result" + ) + ); +} + +export function classifyClaudeSessionEntries( + entries: Record[] +): ProviderSessionState { + let lastTurnDuration = -1; + let lastAssistant = -1; + let lastHumanUser = -1; + let lastToolResult = -1; + + entries.forEach((entry, index) => { + if (entry.type === "system" && entry.subtype === "turn_duration") { + lastTurnDuration = index; + } + if ( + entry.type === "assistant" && + assistantStopReason(entry) !== undefined + ) { + lastAssistant = index; + } + if (isHumanUserEntry(entry)) lastHumanUser = index; + if (isToolResultEntry(entry)) lastToolResult = index; + }); + + const lastSemantic = Math.max(lastAssistant, lastHumanUser, lastToolResult); + if (lastTurnDuration > lastSemantic) { + return { state: "complete", reason: "turn-duration" }; + } + if (lastHumanUser > lastAssistant) { + return { state: "interrupted", reason: "unanswered-user-message" }; + } + if (lastToolResult > lastAssistant) { + return { state: "interrupted", reason: "tool-result-without-follow-up" }; + } + if (lastAssistant >= 0) { + const reason = assistantStopReason(entries[lastAssistant]!); + if (reason === "end_turn" || reason === "stop_sequence") { + return { state: "complete", reason: `assistant-${reason}` }; + } + if (reason === "tool_use" || reason === null) { + return { + state: "interrupted", + reason: + reason === "tool_use" + ? "dangling-tool-use" + : "incomplete-assistant-chunk", + }; + } + } + return { state: "unknown", reason: "no-decisive-turn-marker" }; +} + +export async function inspectClaudeSessionState( + cwd: string, + sessionId: string +): Promise { + if (!SESSION_ID_RE.test(sessionId)) { + return { state: "unknown", reason: "invalid-session-id" }; + } + const filePath = path.join(cwdToClaudeProjectDir(cwd), `${sessionId}.jsonl`); + const entries = await readJsonlTail(filePath); + if (entries === null) + return { state: "unknown", reason: "missing-or-changing-log" }; + if (entries.length === 0) return { state: "unknown", reason: "empty-log" }; + return classifyClaudeSessionEntries(entries); +} diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 35bd95e73..20de87884 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -322,6 +322,9 @@ export function buildLaunchGuidance( ? "Report status with dispatch_event as you work and before your final response — blocked means genuinely stuck, not an error you're about to fix. Your reported status is verified against session activity and auto-corrected." : "Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match." ); + rules.push( + "Once you accept a task, do not end a turn after only announcing a plan or status. Continue into substantive work in the same turn, or explicitly report waiting_user or blocked when you genuinely cannot proceed." + ); if (chatSurface) { rules.push(CHAT_SURFACE_GUIDANCE_RULE); } diff --git a/apps/server/test/provider-session-state.test.ts b/apps/server/test/provider-session-state.test.ts new file mode 100644 index 000000000..3221b2e51 --- /dev/null +++ b/apps/server/test/provider-session-state.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { classifyClaudeSessionEntries } from "../src/agents/provider-session-state.js"; + +const assistant = (stopReason: unknown): Record => ({ + type: "assistant", + message: { stop_reason: stopReason }, +}); +const human = (): Record => ({ + type: "user", + message: { content: "continue the task" }, +}); +const toolResult = (): Record => ({ + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: "tool_1" }] }, +}); +const turnDuration = (): Record => ({ + type: "system", + subtype: "turn_duration", + durationMs: 42, +}); + +describe("classifyClaudeSessionEntries", () => { + it("recognizes an explicit provider turn-end marker", () => { + expect( + classifyClaudeSessionEntries([ + human(), + assistant("end_turn"), + turnDuration(), + ]) + ).toEqual({ state: "complete", reason: "turn-duration" }); + }); + + it("keeps completion when metadata follows turn duration", () => { + expect( + classifyClaudeSessionEntries([ + human(), + assistant("end_turn"), + turnDuration(), + { type: "file-history-snapshot" }, + ]) + ).toEqual({ state: "complete", reason: "turn-duration" }); + }); + + it("recognizes end_turn when the duration marker was not flushed", () => { + expect( + classifyClaudeSessionEntries([human(), assistant("end_turn")]) + ).toEqual({ + state: "complete", + reason: "assistant-end_turn", + }); + }); + + it("recognizes a dangling provider tool call as interrupted", () => { + expect( + classifyClaudeSessionEntries([human(), assistant("tool_use")]) + ).toEqual({ + state: "interrupted", + reason: "dangling-tool-use", + }); + }); + + it("recognizes a tool result without a follow-up assistant message", () => { + expect( + classifyClaudeSessionEntries([ + human(), + assistant("tool_use"), + toolResult(), + ]) + ).toEqual({ + state: "interrupted", + reason: "tool-result-without-follow-up", + }); + }); + + it("recognizes an unanswered human message after a previous turn", () => { + expect( + classifyClaudeSessionEntries([ + human(), + assistant("end_turn"), + turnDuration(), + human(), + ]) + ).toEqual({ state: "interrupted", reason: "unanswered-user-message" }); + }); + + it("fails closed for an unrecognized transcript shape", () => { + expect(classifyClaudeSessionEntries([{ type: "summary" }])).toEqual({ + state: "unknown", + reason: "no-decisive-turn-marker", + }); + }); +}); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index d7112eb40..764bc3a2e 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -1111,6 +1111,18 @@ describe("buildLaunchGuidance — trimmed variant", () => { expect(text).toContain("auto-corrected"); }); + it("requires accepted tasks to continue past plan-only turns", () => { + for (const agentType of ["claude", "codex"] as const) { + for (const trimmedGuidance of [false, true]) { + const text = guidance({ agentType, trimmedGuidance }); + expect(text).toContain( + "do not end a turn after only announcing a plan or status" + ); + expect(text).toContain("Continue into substantive work"); + } + } + }); + it("folds the two pin rules into one", () => { const full = guidance({ agentType: "claude" }); const text = guidance({ agentType: "claude", trimmedGuidance: true }); diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 9de6eb372..dd1ba4b0f 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -790,7 +790,15 @@ function QuestionOptions({
Answered - · {answer.label ?? answer.value} + + + + {answer.label ?? answer.value} + +
)}
@@ -817,7 +825,12 @@ function QuestionOptions({ onClick={() => onAnswer(option)} > {chosen ? : null} - {option.label} + + {option.label} + ); })} diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index 39eaa3374..cc0659164 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -887,7 +887,7 @@ describe("ChatFeed", () => { expect(update!.textContent).toContain("Still going"); }); - it("renders an unanswered question with clickable options", () => { + it("renders an unanswered question with clickable Markdown options", () => { const { onAnswer } = renderFeed([ chat( message({ @@ -895,7 +895,10 @@ describe("ChatFeed", () => { kind: "question", text: "Which one?", question: { - options: [{ label: "Alpha", value: "a" }, { label: "Beta" }], + options: [ + { label: "**Alpha** uses `a`", value: "a" }, + { label: "Beta" }, + ], allowFreeform: true, }, }) @@ -906,6 +909,9 @@ describe("ChatFeed", () => { const options = screen.getAllByTestId("chat-question-option"); expect(options).toHaveLength(2); expect(options.every((o) => !(o as HTMLButtonElement).disabled)).toBe(true); + expect(options[0]!.textContent).toBe("Alpha uses a"); + expect(options[0]!.querySelector("strong")?.textContent).toBe("Alpha"); + expect(options[0]!.querySelector("code")?.textContent).toBe("a"); fireEvent.click(options[1]!); expect(onAnswer).toHaveBeenCalledWith("q1", { label: "Beta" }); @@ -924,12 +930,15 @@ describe("ChatFeed", () => { kind: "question", text: "Which one?", question: { - options: [{ label: "Alpha", value: "a" }, { label: "Beta" }], + options: [ + { label: "**Alpha** uses `a`", value: "a" }, + { label: "Beta" }, + ], allowFreeform: true, }, answer: { value: "a", - label: "Alpha", + label: "**Alpha** uses `a`", replyMessageId: "u9", answeredAt: "2026-09-02T10:01:00.000Z", }, @@ -938,9 +947,11 @@ describe("ChatFeed", () => { ]); expect(screen.queryByTestId("chat-needs-reply")).toBeNull(); expect(screen.queryByText("Or type a reply below.")).toBeNull(); - expect(screen.getByTestId("chat-question-options").textContent).toContain( - "Answered" - ); + const card = screen.getByTestId("chat-question-options"); + expect(card.textContent).toContain("Answered"); + expect(card.textContent).not.toContain("**Alpha**"); + expect(card.querySelector("strong")?.textContent).toBe("Alpha"); + expect(card.querySelector("code")?.textContent).toBe("a"); const options = screen.getAllByTestId("chat-question-option"); expect(options.every((o) => (o as HTMLButtonElement).disabled)).toBe(true); expect(options[0]!.getAttribute("aria-pressed")).toBe("true"); From ac441e0ffcf7f3c30f94cd861a5889dd187fe3f4 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 22:43:53 -0700 Subject: [PATCH 2/6] Avoid automatic replay after agent restart --- apps/server/src/agents/manager.ts | 30 --- .../src/agents/provider-session-state.ts | 177 ------------------ .../test/provider-session-state.test.ts | 93 --------- 3 files changed, 300 deletions(-) delete mode 100644 apps/server/src/agents/provider-session-state.ts delete mode 100644 apps/server/test/provider-session-state.test.ts diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index e6caf3f43..cdc88739d 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -26,7 +26,6 @@ import { getActivePersonality } from "../db/personalities.js"; import { isTrimmedLaunchGuidanceEnabled } from "../launch-guidance-settings.js"; import { isChatSurfaceEnabled } from "../chat-surface-settings.js"; import { findCodexSessionId } from "./codex-sessions.js"; -import { inspectClaudeSessionState } from "./provider-session-state.js"; import { harvestTokenUsage } from "./token-harvester.js"; import { errorMessage } from "../shared/lib/error-message.js"; import { @@ -1204,34 +1203,6 @@ export class AgentManager { // Use a conditional UPDATE to avoid races from concurrent start requests. let cliSessionId = agent.cliSessionId; let shouldResume = !!cliSessionId; - let resumeRecoveryPrompt: string | undefined; - if (cliSessionId && agent.type === "claude") { - const effectiveCwd = agent.worktreePath ?? agent.cwd; - try { - const sessionState = await inspectClaudeSessionState( - effectiveCwd, - cliSessionId - ); - if (sessionState.state === "interrupted") { - resumeRecoveryPrompt = - "Dispatch detected that the previous provider turn ended without a clean completion marker. Resume the interrupted task now. First reconcile any in-flight tool state from the transcript; do not repeat completed operations, and do not merely restate a plan."; - this.logger.info( - { - agentId: id, - provider: "claude", - sessionState: sessionState.state, - reason: sessionState.reason, - }, - "Resuming interrupted provider session with recovery guidance" - ); - } - } catch (error) { - this.logger.debug( - { err: error, agentId: id, provider: "claude" }, - "Provider session inspection failed; preserving native resume" - ); - } - } if (!cliSessionId && agent.type === "claude") { cliSessionId = randomUUID(); cliSessionId = await this.claimCliSessionId(id, cliSessionId); @@ -1286,7 +1257,6 @@ export class AgentManager { autoReview: !agent.persona && (agent.autoReview ?? false), trimmedGuidance, chatSurface, - initialPrompt: resumeRecoveryPrompt, personalityPrompt: personality?.prompt ?? null, model: agent.model ?? undefined, } diff --git a/apps/server/src/agents/provider-session-state.ts b/apps/server/src/agents/provider-session-state.ts deleted file mode 100644 index 8b3b7e0d9..000000000 --- a/apps/server/src/agents/provider-session-state.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { open, stat } from "node:fs/promises"; -import path from "node:path"; - -import { cwdToClaudeProjectDir } from "./token-harvester.js"; - -const MAX_TAIL_BYTES = 256 * 1024; -const SESSION_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; - -export type ProviderSessionState = - | { state: "complete"; reason: string } - | { state: "interrupted"; reason: string } - | { state: "unknown"; reason: string }; - -async function readJsonlTail( - filePath: string -): Promise[] | null> { - let before: Awaited>; - try { - before = await stat(filePath); - } catch { - return null; - } - if (!before.isFile() || before.size === 0) return []; - - const start = Math.max(0, before.size - MAX_TAIL_BYTES); - const length = before.size - start; - const handle = await open(filePath, "r"); - let bytesRead = 0; - let buffer: Buffer; - try { - buffer = Buffer.alloc(length); - ({ bytesRead } = await handle.read(buffer, 0, length, start)); - } finally { - await handle.close(); - } - - const after = await stat(filePath).catch(() => null); - if ( - !after || - after.size !== before.size || - after.mtimeMs !== before.mtimeMs - ) { - return null; - } - - let text = buffer.subarray(0, bytesRead).toString("utf8"); - if (start > 0) { - const newline = text.indexOf("\n"); - if (newline < 0) return null; - text = text.slice(newline + 1); - } - const lines = text.split("\n"); - if (lines.at(-1)?.trim()) return null; - lines.pop(); - - const entries: Record[] = []; - for (const line of lines) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) - return null; - entries.push(parsed as Record); - } catch { - return null; - } - } - return entries; -} - -function assistantStopReason(entry: Record): unknown { - if (entry.type !== "assistant") return undefined; - const message = entry.message; - if (!message || typeof message !== "object" || Array.isArray(message)) - return undefined; - return (message as Record).stop_reason; -} - -function isHumanUserEntry(entry: Record): boolean { - if (entry.type !== "user") return false; - const message = entry.message; - if (!message || typeof message !== "object" || Array.isArray(message)) - return false; - const content = (message as Record).content; - if (typeof content === "string") return true; - if (!Array.isArray(content)) return false; - return !content.every( - (part) => - !!part && - typeof part === "object" && - !Array.isArray(part) && - (part as Record).type === "tool_result" - ); -} - -function isToolResultEntry(entry: Record): boolean { - if (entry.type !== "user") return false; - const message = entry.message; - if (!message || typeof message !== "object" || Array.isArray(message)) - return false; - const content = (message as Record).content; - return ( - Array.isArray(content) && - content.some( - (part) => - !!part && - typeof part === "object" && - !Array.isArray(part) && - (part as Record).type === "tool_result" - ) - ); -} - -export function classifyClaudeSessionEntries( - entries: Record[] -): ProviderSessionState { - let lastTurnDuration = -1; - let lastAssistant = -1; - let lastHumanUser = -1; - let lastToolResult = -1; - - entries.forEach((entry, index) => { - if (entry.type === "system" && entry.subtype === "turn_duration") { - lastTurnDuration = index; - } - if ( - entry.type === "assistant" && - assistantStopReason(entry) !== undefined - ) { - lastAssistant = index; - } - if (isHumanUserEntry(entry)) lastHumanUser = index; - if (isToolResultEntry(entry)) lastToolResult = index; - }); - - const lastSemantic = Math.max(lastAssistant, lastHumanUser, lastToolResult); - if (lastTurnDuration > lastSemantic) { - return { state: "complete", reason: "turn-duration" }; - } - if (lastHumanUser > lastAssistant) { - return { state: "interrupted", reason: "unanswered-user-message" }; - } - if (lastToolResult > lastAssistant) { - return { state: "interrupted", reason: "tool-result-without-follow-up" }; - } - if (lastAssistant >= 0) { - const reason = assistantStopReason(entries[lastAssistant]!); - if (reason === "end_turn" || reason === "stop_sequence") { - return { state: "complete", reason: `assistant-${reason}` }; - } - if (reason === "tool_use" || reason === null) { - return { - state: "interrupted", - reason: - reason === "tool_use" - ? "dangling-tool-use" - : "incomplete-assistant-chunk", - }; - } - } - return { state: "unknown", reason: "no-decisive-turn-marker" }; -} - -export async function inspectClaudeSessionState( - cwd: string, - sessionId: string -): Promise { - if (!SESSION_ID_RE.test(sessionId)) { - return { state: "unknown", reason: "invalid-session-id" }; - } - const filePath = path.join(cwdToClaudeProjectDir(cwd), `${sessionId}.jsonl`); - const entries = await readJsonlTail(filePath); - if (entries === null) - return { state: "unknown", reason: "missing-or-changing-log" }; - if (entries.length === 0) return { state: "unknown", reason: "empty-log" }; - return classifyClaudeSessionEntries(entries); -} diff --git a/apps/server/test/provider-session-state.test.ts b/apps/server/test/provider-session-state.test.ts deleted file mode 100644 index 3221b2e51..000000000 --- a/apps/server/test/provider-session-state.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { classifyClaudeSessionEntries } from "../src/agents/provider-session-state.js"; - -const assistant = (stopReason: unknown): Record => ({ - type: "assistant", - message: { stop_reason: stopReason }, -}); -const human = (): Record => ({ - type: "user", - message: { content: "continue the task" }, -}); -const toolResult = (): Record => ({ - type: "user", - message: { content: [{ type: "tool_result", tool_use_id: "tool_1" }] }, -}); -const turnDuration = (): Record => ({ - type: "system", - subtype: "turn_duration", - durationMs: 42, -}); - -describe("classifyClaudeSessionEntries", () => { - it("recognizes an explicit provider turn-end marker", () => { - expect( - classifyClaudeSessionEntries([ - human(), - assistant("end_turn"), - turnDuration(), - ]) - ).toEqual({ state: "complete", reason: "turn-duration" }); - }); - - it("keeps completion when metadata follows turn duration", () => { - expect( - classifyClaudeSessionEntries([ - human(), - assistant("end_turn"), - turnDuration(), - { type: "file-history-snapshot" }, - ]) - ).toEqual({ state: "complete", reason: "turn-duration" }); - }); - - it("recognizes end_turn when the duration marker was not flushed", () => { - expect( - classifyClaudeSessionEntries([human(), assistant("end_turn")]) - ).toEqual({ - state: "complete", - reason: "assistant-end_turn", - }); - }); - - it("recognizes a dangling provider tool call as interrupted", () => { - expect( - classifyClaudeSessionEntries([human(), assistant("tool_use")]) - ).toEqual({ - state: "interrupted", - reason: "dangling-tool-use", - }); - }); - - it("recognizes a tool result without a follow-up assistant message", () => { - expect( - classifyClaudeSessionEntries([ - human(), - assistant("tool_use"), - toolResult(), - ]) - ).toEqual({ - state: "interrupted", - reason: "tool-result-without-follow-up", - }); - }); - - it("recognizes an unanswered human message after a previous turn", () => { - expect( - classifyClaudeSessionEntries([ - human(), - assistant("end_turn"), - turnDuration(), - human(), - ]) - ).toEqual({ state: "interrupted", reason: "unanswered-user-message" }); - }); - - it("fails closed for an unrecognized transcript shape", () => { - expect(classifyClaudeSessionEntries([{ type: "summary" }])).toEqual({ - state: "unknown", - reason: "no-decisive-turn-marker", - }); - }); -}); From 820bb645d4a12e9fd4116df0c091ff910e42b481 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 22:59:26 -0700 Subject: [PATCH 3/6] Polish Markdown question card rendering --- .../src/components/app/chat/chat-entries.tsx | 26 +++++++++-------- .../components/app/chat/chat-feed.test.tsx | 28 ++++++++++++++++++ apps/web/src/components/ui/markdown.tsx | 29 ++++++++++++++++++- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index dd1ba4b0f..8a3c03124 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -767,6 +767,12 @@ function QuestionOptions({ if (!question) return null; const answer = message.answer; const open = answer === null; + const answeredOption = answer + ? (question.options.find( + (option) => (option.value ?? option.label) === answer.value + ) ?? null) + : null; + const answerDisplay = answeredOption?.label ?? answer?.value ?? ""; const optionsDisabled = answer !== null || answering || answersDisabled; return (
- - {answer.label ?? answer.value} - + {answeredOption ? ( + + {answerDisplay} + + ) : ( + {answerDisplay} + )}
)} @@ -825,12 +832,7 @@ function QuestionOptions({ onClick={() => onAnswer(option)} > {chosen ? : null} - - {option.label} - + {option.label} ); })} diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index cc0659164..36351aa9c 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -960,6 +960,34 @@ describe("ChatFeed", () => { expect(onAnswer).not.toHaveBeenCalled(); }); + it("keeps a freeform answer literal in the answered summary", () => { + renderFeed([ + chat( + message({ + id: "q-freeform", + kind: "question", + text: "Other?", + question: { + options: [{ label: "Suggested" }], + allowFreeform: true, + }, + answer: { + value: "__init__.py uses `literal` *marks*", + label: "__init__.py uses `literal` *marks*", + replyMessageId: "u10", + answeredAt: "2026-09-02T10:01:00.000Z", + }, + }) + ), + ]); + + const card = screen.getByTestId("chat-question-options"); + expect(card.textContent).toContain("__init__.py uses `literal` *marks*"); + expect(card.querySelector("strong")).toBeNull(); + expect(card.querySelector("code")).toBeNull(); + expect(card.querySelector("em")).toBeNull(); + }); + it("locks options and hides the freeform hint while answers are unavailable", () => { renderFeed( [ diff --git a/apps/web/src/components/ui/markdown.tsx b/apps/web/src/components/ui/markdown.tsx index fbee1424f..8b7fd61ab 100644 --- a/apps/web/src/components/ui/markdown.tsx +++ b/apps/web/src/components/ui/markdown.tsx @@ -32,7 +32,7 @@ function getCodeBlock( type MarkdownProps = { children: string; className?: string; - variant?: "default" | "pin" | "caption"; + variant?: "default" | "pin" | "caption" | "inline"; // Colors h1/h2 for skimming a long document (see MarkdownDefault). Off by // default: most `default`-variant consumers are compact cards that pass // their own dimmed base color (e.g. text-muted-foreground, text-foreground/85) @@ -55,6 +55,10 @@ export const Markdown = memo(function Markdown({ return {children}; } + if (variant === "inline") { + return {children}; + } + return ( {children} @@ -62,6 +66,29 @@ export const Markdown = memo(function Markdown({ ); }); +function MarkdownInline({ + children, + className, +}: Pick): JSX.Element { + return ( + + + {children} + + + ); +} + /** * Single-line muted markdown for subtitles (e.g. a shortcut pin's caption). * Inline marks only — block elements are unwrapped so the caption can never From 464e3c530661c62b4fd25c8fde45dc6d4475724b Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 23:12:16 -0700 Subject: [PATCH 4/6] Render option answer replies consistently --- .../src/components/app/chat/chat-entries.tsx | 14 +++++++++- .../web/src/components/app/chat/chat-feed.tsx | 28 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 8a3c03124..45c1f63b9 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -139,6 +139,8 @@ export type FeedContext = { agentType?: string | null; /** Other agents, for a peer post's avatar and relation; absent until loaded. */ peers?: PeerDirectory; + /** Chat messages by id, used to render answer-origin replies consistently. */ + chatMessages?: ReadonlyMap; onOpenMedia: (mediaId: number) => void; /** Opens a review in the Reviews sidebar, expanded. */ onOpenReview?: (reviewId: number) => void; @@ -924,6 +926,12 @@ export const ChatMessageView = memo(function ChatMessageView({ ) : undefined; if (message.authorKind === "user") { + const repliedQuestion = message.replyTo + ? ctx.chatMessages?.get(message.replyTo) + : undefined; + const repliedOption = repliedQuestion?.question?.options.find( + (option) => (option.value ?? option.label) === message.text + ); return ( - {message.text} + {repliedOption ? ( + {repliedOption.label} + ) : ( + message.text + )}
) : null} diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 950d54d5c..f17491604 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -329,7 +329,23 @@ export function ChatFeed({ answersDisabled = false, onAnswer, }: ChatFeedProps): JSX.Element { - const rows = useMemo(() => layoutFeed(entries, ctx), [entries, ctx]); + const messageDirectory = useMemo( + () => + new Map( + entries + .filter((entry) => entry.type === "chat") + .map((entry) => [entry.message.id, entry.message] as const) + ), + [entries] + ); + const rowContext = useMemo( + () => ({ ...ctx, chatMessages: messageDirectory }), + [ctx, messageDirectory] + ); + const rows = useMemo( + () => layoutFeed(entries, rowContext), + [entries, rowContext] + ); const entering = useEnteringEntries(entries); // Consecutive status lines sit as one quiet cluster between posts, so they @@ -397,7 +413,7 @@ export function ChatFeed({ held={heldMessageId === entry.message.id} grouped={row.grouped} rule={row.rule} - ctx={ctx} + ctx={rowContext} answering={answeringMessageId === entry.message.id} answersDisabled={answersDisabled} onAnswer={onAnswer} @@ -409,7 +425,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={ctx} + ctx={rowContext} /> ); case "media": @@ -418,7 +434,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={ctx} + ctx={rowContext} /> ); case "review": @@ -427,7 +443,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={ctx} + ctx={rowContext} /> ); case "pin": @@ -436,7 +452,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={ctx} + ctx={rowContext} /> ); } From f3a931bd7cbd228536439b28d4bdf084efd7ea26 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 23:17:44 -0700 Subject: [PATCH 5/6] Preserve chat row memoization --- .../src/components/app/chat/chat-entries.tsx | 15 ++++------ .../web/src/components/app/chat/chat-feed.tsx | 29 ++++++++++--------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 45c1f63b9..f1cef9b89 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -139,8 +139,6 @@ export type FeedContext = { agentType?: string | null; /** Other agents, for a peer post's avatar and relation; absent until loaded. */ peers?: PeerDirectory; - /** Chat messages by id, used to render answer-origin replies consistently. */ - chatMessages?: ReadonlyMap; onOpenMedia: (mediaId: number) => void; /** Opens a review in the Reviews sidebar, expanded. */ onOpenReview?: (reviewId: number) => void; @@ -908,6 +906,7 @@ export const ChatMessageView = memo(function ChatMessageView({ ctx, answering, answersDisabled = false, + answeredOptionLabel = null, onAnswer, }: { message: ChatMessage; @@ -919,6 +918,8 @@ export const ChatMessageView = memo(function ChatMessageView({ answering: boolean; /** Answers go through the same injection as the composer; lock them together. */ answersDisabled?: boolean; + /** Canonical option label when this user row answers a declared option. */ + answeredOptionLabel?: string | null; onAnswer: (messageId: string, option: ChatQuestionOption) => void; }): JSX.Element { const copyAction = message.text ? ( @@ -926,12 +927,6 @@ export const ChatMessageView = memo(function ChatMessageView({ ) : undefined; if (message.authorKind === "user") { - const repliedQuestion = message.replyTo - ? ctx.chatMessages?.get(message.replyTo) - : undefined; - const repliedOption = repliedQuestion?.question?.options.find( - (option) => (option.value ?? option.label) === message.text - ); return ( - {repliedOption ? ( - {repliedOption.label} + {answeredOptionLabel ? ( + {answeredOptionLabel} ) : ( message.text )} diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index f17491604..509bf94e6 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -338,14 +338,7 @@ export function ChatFeed({ ), [entries] ); - const rowContext = useMemo( - () => ({ ...ctx, chatMessages: messageDirectory }), - [ctx, messageDirectory] - ); - const rows = useMemo( - () => layoutFeed(entries, rowContext), - [entries, rowContext] - ); + const rows = useMemo(() => layoutFeed(entries, ctx), [entries, ctx]); const entering = useEnteringEntries(entries); // Consecutive status lines sit as one quiet cluster between posts, so they @@ -404,6 +397,15 @@ export function ChatFeed({ } if (row.kind === "status") return null; const entry = row.entry; + const answeredOptionLabel = (() => { + if (entry.type !== "chat" || !entry.message.replyTo) return null; + const question = messageDirectory.get(entry.message.replyTo); + const option = question?.question?.options.find( + (candidate) => + (candidate.value ?? candidate.label) === entry.message.text + ); + return option?.label ?? null; + })(); const view = (() => { switch (entry.type) { case "chat": @@ -413,9 +415,10 @@ export function ChatFeed({ held={heldMessageId === entry.message.id} grouped={row.grouped} rule={row.rule} - ctx={rowContext} + ctx={ctx} answering={answeringMessageId === entry.message.id} answersDisabled={answersDisabled} + answeredOptionLabel={answeredOptionLabel} onAnswer={onAnswer} /> ); @@ -425,7 +428,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={rowContext} + ctx={ctx} /> ); case "media": @@ -434,7 +437,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={rowContext} + ctx={ctx} /> ); case "review": @@ -443,7 +446,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={rowContext} + ctx={ctx} /> ); case "pin": @@ -452,7 +455,7 @@ export function ChatFeed({ entry={entry} grouped={row.grouped} rule={row.rule} - ctx={rowContext} + ctx={ctx} /> ); } From 57086a6d66320e5e5e862f97092326d8c7d3b0da Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Sun, 6 Sep 2026 23:20:10 -0700 Subject: [PATCH 6/6] Match answer replies through question state --- .../src/components/app/chat/chat-feed.test.tsx | 17 +++++++++++++++++ apps/web/src/components/app/chat/chat-feed.tsx | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index 36351aa9c..e7166ad10 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -944,6 +944,17 @@ describe("ChatFeed", () => { }, }) ), + chat( + message({ + id: "u9", + authorKind: "user", + text: "**Alpha** uses `a`", + replyTo: "q1", + delivered: true, + createdAt: "2026-09-02T10:01:00.000Z", + updatedAt: "2026-09-02T10:01:00.000Z", + }) + ), ]); expect(screen.queryByTestId("chat-needs-reply")).toBeNull(); expect(screen.queryByText("Or type a reply below.")).toBeNull(); @@ -952,6 +963,12 @@ describe("ChatFeed", () => { expect(card.textContent).not.toContain("**Alpha**"); expect(card.querySelector("strong")?.textContent).toBe("Alpha"); expect(card.querySelector("code")?.textContent).toBe("a"); + const userReply = screen + .getAllByTestId("chat-message") + .find((row) => row.getAttribute("data-message-id") === "u9")!; + expect(userReply.textContent).not.toContain("**Alpha**"); + expect(userReply.querySelector("strong")?.textContent).toBe("Alpha"); + expect(userReply.querySelector("code")?.textContent).toBe("a"); const options = screen.getAllByTestId("chat-question-option"); expect(options.every((o) => (o as HTMLButtonElement).disabled)).toBe(true); expect(options[0]!.getAttribute("aria-pressed")).toBe("true"); diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 509bf94e6..6fd9856c0 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -400,9 +400,11 @@ export function ChatFeed({ const answeredOptionLabel = (() => { if (entry.type !== "chat" || !entry.message.replyTo) return null; const question = messageDirectory.get(entry.message.replyTo); - const option = question?.question?.options.find( + if (question?.answer?.replyMessageId !== entry.message.id) + return null; + const option = question.question?.options.find( (candidate) => - (candidate.value ?? candidate.label) === entry.message.text + (candidate.value ?? candidate.label) === question.answer?.value ); return option?.label ?? null; })();