diff --git a/assistant/src/__tests__/conversation-agent-loop.test.ts b/assistant/src/__tests__/conversation-agent-loop.test.ts index b5cff4feae6..32823fa0568 100644 --- a/assistant/src/__tests__/conversation-agent-loop.test.ts +++ b/assistant/src/__tests__/conversation-agent-loop.test.ts @@ -3444,6 +3444,400 @@ describe("session-agent-loop", () => { }); }); + describe("partial persistence", () => { + // The legacy flow reserves an empty assistant row at `llm_call_started` + // (`content: "[]"`) and never touches it again until + // `handleMessageComplete` fires the single authoritative + // `updateContent`. Between those events the row is empty for the full + // duration of a turn — a browser refresh mid-turn sees nothing where + // the in-progress assistant reply should be. + // + // Partial persistence closes that durability gap with a debounced + // flush from `handleTextDelta` (250ms timer). `handleToolUse` + // intentionally does NOT flush — `AgentLoop.run` emits `tool_use` + // strictly AFTER `message_complete`, so any flush from that handler + // would land after the authoritative finalize and overwrite the + // finalized row. The indexer + projector still fire ONLY at + // `message_complete` — partial rows are never indexed. + // + // These tests pin down the wire-level contract by counting + // `updateMessageContent` calls and inspecting the JSON payload of the + // partial-flush writes. The indexing / sync-invalidation paths are + // covered by the pre-allocation block above. + + test("debounced time gate flushes one partial write after PARTIAL_PERSIST_DEBOUNCE_MS", async () => { + mockMessageById = { + id: "msg-reserve", + conversationId: "test-conv", + createdAt: 1234567, + role: "assistant", + content: "[]", + metadata: null, + }; + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + // Two small deltas — well under the 1024-char size gate — should + // schedule a single debounced flush. + onEvent({ type: "text_delta", text: "Hello, " }); + onEvent({ type: "text_delta", text: "world." }); + // Wait long enough for the 250ms debounce to fire. + await new Promise((resolve) => setTimeout(resolve, 1100)); + await onEvent({ + type: "message_complete", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello, world." }], + }, + }); + onEvent({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + model: "test", + providerDurationMs: 50, + }); + return [ + ...messages, + { + role: "assistant" as const, + content: [ + { type: "text", text: "Hello, world." }, + ] as ContentBlock[], + }, + ]; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + // Exactly two `updateContent` calls land: + // 1. the debounced partial flush after both deltas accumulated, and + // 2. the final authoritative flush in `handleMessageComplete`. + // Without the debounce gate this would be one-per-delta + one final + // (3). Without the partial flush at all it would be just 1. + expect(updateMessageContentMock).toHaveBeenCalledTimes(2); + const calls = updateMessageContentMock.mock.calls as unknown as Array< + [string, string] + >; + const partialFlush = calls[0]; + expect(partialFlush?.[0]).toBe("msg-reserve"); + const partialBlocks = JSON.parse(partialFlush?.[1] ?? "[]") as Array<{ + type: string; + text?: string; + }>; + expect(partialBlocks).toEqual([{ type: "text", text: "Hello, world." }]); + }); + + test("handleToolUse does NOT trigger a partial flush of its own", async () => { + // `AgentLoop.run` emits `tool_use` strictly AFTER `message_complete`, + // so a flush from the tool_use handler would land after the + // authoritative final `updateContent` and overwrite the finalized + // row (Codex P1 / Vargas review feedback). The handler must be a + // no-op for the partial-persist accumulator. + mockMessageById = { + id: "msg-reserve", + conversationId: "test-conv", + createdAt: 1234567, + role: "assistant", + content: "[]", + metadata: null, + }; + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + // No text delta — only a tool_use. If `handleToolUse` were + // flushing, this would land a partial write before + // `message_complete`. + onEvent({ + type: "tool_use", + id: "tu-no-flush", + name: "file_read", + input: { path: "/foo" }, + }); + // Yield a microtask so any (incorrectly) fire-and-forget + // pipeline call has a chance to land before message_complete. + await new Promise((resolve) => setImmediate(resolve)); + onEvent({ + type: "tool_result", + toolUseId: "tu-no-flush", + content: "ok", + isError: false, + }); + await onEvent({ + type: "message_complete", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tu-no-flush", + name: "file_read", + input: { path: "/foo" }, + }, + ], + }, + }); + onEvent({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + model: "test", + providerDurationMs: 50, + }); + return [ + ...messages, + { + role: "assistant" as const, + content: [ + { + type: "tool_use", + id: "tu-no-flush", + name: "file_read", + input: { path: "/foo" }, + }, + ] as ContentBlock[], + }, + ]; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + // Only the authoritative final flush from `handleMessageComplete` + // lands. A partial flush from `handleToolUse` would have made this + // 2; that's the regression this test guards against. + expect(updateMessageContentMock).toHaveBeenCalledTimes(1); + }); + + test("handleMessageComplete clears any pending debounce timer before the final flush", async () => { + mockMessageById = { + id: "msg-reserve", + conversationId: "test-conv", + createdAt: 1234567, + role: "assistant", + content: "[]", + metadata: null, + }; + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + // Short delta — schedules a debounce timer but does NOT trip the + // size gate. message_complete then arrives immediately after, + // before the 250ms timer can fire. + onEvent({ type: "text_delta", text: "Quick reply." }); + await onEvent({ + type: "message_complete", + message: { + role: "assistant", + content: [{ type: "text", text: "Quick reply." }], + }, + }); + onEvent({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + model: "test", + providerDurationMs: 50, + }); + // Wait past the original debounce window to prove a late timer + // does NOT fire a stray partial flush. + await new Promise((resolve) => setTimeout(resolve, 1100)); + return [ + ...messages, + { + role: "assistant" as const, + content: [{ type: "text", text: "Quick reply." }] as ContentBlock[], + }, + ]; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + // Only the final flush from `handleMessageComplete` lands. The + // debounced partial would have fired around T+250ms; the timer-clear + // at the top of `handleMessageComplete` cancels it. + expect(updateMessageContentMock).toHaveBeenCalledTimes(1); + }); + + test("partial flushes never trigger the indexer or attention projector", async () => { + mockMessageById = { + id: "msg-reserve", + conversationId: "test-conv", + createdAt: 1234567, + role: "assistant", + content: "[]", + metadata: null, + }; + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + onEvent({ type: "text_delta", text: "hello world" }); + // Wait past the 250ms debounce so the partial flush definitely + // lands BEFORE message_complete fires. + await new Promise((resolve) => setTimeout(resolve, 1100)); + // Snapshot the indexer/projector call counts AFTER the partial + // flush has run but BEFORE message_complete. They must be zero. + const indexerCallsBeforeComplete = + indexMessageNowMock.mock.calls.length; + const projectorCallsBeforeComplete = + projectAssistantMessageMock.mock.calls.length; + // Stash on a side channel the assertion phase can read. + ( + ctx as unknown as { __partialSnapshot?: [number, number] } + ).__partialSnapshot = [ + indexerCallsBeforeComplete, + projectorCallsBeforeComplete, + ]; + await onEvent({ + type: "message_complete", + message: { + role: "assistant", + content: [{ type: "text", text: "hello world" }], + }, + }); + onEvent({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + model: "test", + providerDurationMs: 50, + }); + return [ + ...messages, + { + role: "assistant" as const, + content: [{ type: "text", text: "hello world" }] as ContentBlock[], + }, + ]; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + const snapshot = ( + ctx as unknown as { __partialSnapshot?: [number, number] } + ).__partialSnapshot; + expect(snapshot).toBeDefined(); + // Indexer + projector were both ZERO during the mid-turn partial + // flush — they only fire from `handleMessageComplete` after the + // authoritative `updateContent`. + expect(snapshot![0]).toBe(0); + expect(snapshot![1]).toBe(0); + // After the loop completes the indexer + projector each ran exactly + // once (the pre-allocation finalize path). + expect(indexMessageNowMock).toHaveBeenCalledTimes(1); + expect(projectAssistantMessageMock).toHaveBeenCalledTimes(1); + }); + + test("partial flushes redact secrets from text blocks before writing", async () => { + mockMessageById = { + id: "msg-reserve", + conversationId: "test-conv", + createdAt: 1234567, + role: "assistant", + content: "[]", + metadata: null, + }; + // A GitHub PAT-shaped token mid-stream — the redaction discipline + // mirrors `handleMessageComplete`'s final flush so a refresh mid-turn + // never sees plaintext credentials in the persisted row. + const ghToken = "ghp_" + "a".repeat(36); + const payload = "Here's the key: " + ghToken + " enjoy."; + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + onEvent({ type: "text_delta", text: payload }); + // Wait past the 250ms debounce so the partial flush lands. + await new Promise((resolve) => setTimeout(resolve, 1100)); + await onEvent({ + type: "message_complete", + message: { + role: "assistant", + content: [{ type: "text", text: payload }], + }, + }); + onEvent({ + type: "usage", + inputTokens: 10, + outputTokens: 5, + model: "test", + providerDurationMs: 50, + }); + return [ + ...messages, + { + role: "assistant" as const, + content: [{ type: "text", text: payload }] as ContentBlock[], + }, + ]; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + expect(updateMessageContentMock).toHaveBeenCalledTimes(2); + const partialPayload = ( + updateMessageContentMock.mock.calls[0] as unknown as [string, string] + )[1]; + // The raw PAT must never appear in the persisted snapshot. The + // redaction substitute is implementation-defined; the contract here + // is "the literal token string is gone". + expect(partialPayload).not.toContain(ghToken); + }); + + test("provider-error cleanup deletes a row that has accumulated partial content", async () => { + // Regression check: the pre-allocation orphan-cleanup branch + // already deletes the reserved row when the LLM call exits via + // `provider_error`. Partial-persist writes content to that row + // mid-turn; the cleanup must still fire and the row (along with + // its partial content) must still be deleted before the synthetic + // error message lands. + reserveMessageMock.mockImplementationOnce(async () => ({ + id: "msg-orphan-with-partial", + })); + + const agentLoopRun: AgentLoopRun = async (messages, onEvent) => { + await onEvent({ type: "llm_call_started" }); + // A debounced delta lands a partial flush BEFORE the provider + // error fires. + onEvent({ type: "text_delta", text: "hello world" }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + onEvent({ + type: "provider_error", + error: new Error("upstream 500"), + rawRequest: { model: "gpt-4.1", messages: [] }, + actualProvider: "openai", + }); + onEvent({ + type: "error", + error: new Error("upstream 500"), + }); + return messages; + }; + + const ctx = makeCtx({ agentLoopRun }); + await runAgentLoopImpl(ctx, "hi", "msg-1", () => {}); + + // Partial flush fired exactly once (before the provider error). + // The orphan row was then deleted; the synthetic error message is + // inserted separately via `addMessage` (`mock-msg-id`) and never + // touched by `updateContent`. + const partialFlushes = ( + updateMessageContentMock.mock.calls as unknown as Array< + [string, string] + > + ).filter(([id]) => id === "msg-orphan-with-partial"); + expect(partialFlushes).toHaveLength(1); + expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1); + const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [ + string, + ]; + expect(deleteCall[0]).toBe("msg-orphan-with-partial"); + }); + }); + describe("pkbSystemReminderBlock metadata persistence", () => { test("persists pkbSystemReminderBlock in full mode with PKB active", async () => { const reminder = "\npkb content\n"; diff --git a/assistant/src/daemon/conversation-agent-loop-handlers.ts b/assistant/src/daemon/conversation-agent-loop-handlers.ts index c4003906fc7..d28ed866a60 100644 --- a/assistant/src/daemon/conversation-agent-loop-handlers.ts +++ b/assistant/src/daemon/conversation-agent-loop-handlers.ts @@ -67,7 +67,10 @@ import { cleanAssistantContent, drainDirectiveDisplayBuffer, } from "./assistant-attachments.js"; -import type { AgentLoopConversationContext } from "./conversation-agent-loop.js"; +import type { + AgentLoopConversationContext, + AssistantSurface, +} from "./conversation-agent-loop.js"; import { buildConversationErrorMessage, classifyConversationError, @@ -90,6 +93,11 @@ import type { const log = getLogger("agent-loop-handlers"); +// ── Partial-persistence tunables ───────────────────────────────────── +// Debounce for mid-turn `updateContent` writes from text deltas. +// Indexer + projector still fire ONLY at `handleMessageComplete`. +const PARTIAL_PERSIST_DEBOUNCE_MS = 1000; + /** * Build a {@link TurnContext} from the handler's deps for pipeline logging * and plugin attribution. @@ -239,6 +247,12 @@ export interface EventHandlerState { readonly serverToolStartedAt: Map; /** Original input from server_tool_start, keyed by tool_use_id, so the complete handler can read the query. */ readonly serverToolInputs: Map>; + /** Active debounce timer for partial persistence; `undefined` when idle. */ + pendingPartialFlushTimer: ReturnType | undefined; + /** In-flight partial flush write awaited at finalize to avoid overwrite races. */ + pendingPartialFlushPromise: Promise | undefined; + /** Running mirror of the in-flight assistant message's content. */ + currentMessageContent: ContentBlock[]; } /** Immutable context shared across event handlers within a single agent loop run. */ @@ -299,9 +313,118 @@ export function createEventHandlerState(): EventHandlerState { turnStartedAt: Date.now(), serverToolStartedAt: new Map(), serverToolInputs: new Map(), + pendingPartialFlushTimer: undefined, + pendingPartialFlushPromise: undefined, + currentMessageContent: [], }; } +// ── Partial-persistence helpers ────────────────────────────────────── + +/** Canonical persisted-content build: clean → append surfaces → redact. */ +function buildPersistedAssistantContent( + rawBlocks: readonly ContentBlock[], + surfaces: readonly AssistantSurface[], +): ContentBlock[] { + const { cleanedContent } = cleanAssistantContent(rawBlocks); + const cleaned = cleanedContent as ContentBlock[]; + const withSurfaces: ContentBlock[] = [...cleaned]; + for (const surface of surfaces) { + withSurfaces.push({ + type: "ui_surface", + surfaceId: surface.surfaceId, + surfaceType: surface.surfaceType, + title: surface.title, + data: surface.data, + actions: surface.actions, + display: surface.display, + ...(surface.persistent ? { persistent: true } : {}), + } as unknown as ContentBlock); + } + return withSurfaces.map((block) => { + if (block.type === "text") { + const tb = block as Extract; + return { ...tb, text: redactSecrets(tb.text) }; + } + return block; + }); +} + +/** Append a streamed text chunk to `state.currentMessageContent`, fusing into tail text block. */ +function appendTextToCurrentMessage( + state: EventHandlerState, + text: string, +): void { + if (text.length === 0) return; + const tail = state.currentMessageContent.at(-1); + if (tail && tail.type === "text") { + tail.text = tail.text + text; + } else { + state.currentMessageContent.push({ type: "text", text }); + } +} + +/** Reset partial-persist accumulator and any pending flush state. Idempotent. */ +function resetPartialPersistAccumulator(state: EventHandlerState): void { + if (state.pendingPartialFlushTimer !== undefined) { + clearTimeout(state.pendingPartialFlushTimer); + state.pendingPartialFlushTimer = undefined; + } + state.currentMessageContent = []; + state.pendingPartialFlushPromise = undefined; +} + +/** Flush `state.currentMessageContent` to the row via the persistence pipeline. */ +async function flushAccumulatedContent( + state: EventHandlerState, + deps: EventHandlerDeps, +): Promise { + const messageId = state.lastAssistantMessageId; + if (messageId === undefined) return; + if (state.currentMessageContent.length === 0) return; + + const built = buildPersistedAssistantContent(state.currentMessageContent, []); + const contentJson = JSON.stringify(built); + + try { + await runPipeline( + "persistence", + getMiddlewaresFor("persistence"), + defaultPersistenceTerminal, + { + op: "updateContent", + messageId, + content: contentJson, + }, + buildHandlerTurnContext(deps), + DEFAULT_TIMEOUTS.persistence, + ); + } catch (err) { + deps.rlog.warn( + { err, messageId }, + "partial flush of accumulated assistant content failed; finalize at message_complete will recover", + ); + } +} + +/** Schedule a debounced partial flush. First-scheduled wins; no-op when timer pending. */ +function schedulePartialFlush( + state: EventHandlerState, + deps: EventHandlerDeps, +): void { + if (state.pendingPartialFlushTimer !== undefined) return; + state.pendingPartialFlushTimer = setTimeout(() => { + state.pendingPartialFlushTimer = undefined; + const flushPromise = flushAccumulatedContent(state, deps); + state.pendingPartialFlushPromise = flushPromise; + void flushPromise.finally(() => { + if (state.pendingPartialFlushPromise === flushPromise) { + state.pendingPartialFlushPromise = undefined; + } + }); + }, PARTIAL_PERSIST_DEBOUNCE_MS); +} + // ── Shared Helper ──────────────────────────────────────────────────── // providerNameOverride should be supplied when the caller already knows the @@ -545,6 +668,12 @@ export async function handleLlmCallStarted( )) as PersistReserveResult; state.lastAssistantMessageId = reserveResult.message.id; state.assistantRowAwaitingFinalization = true; + // Fresh row → fresh accumulator. If an earlier (failed) LLM call + // within the same run left partial state behind, the + // `assistantRowAwaitingFinalization` cleanup above already deleted + // the orphan row, so the accumulator content would point at a + // non-existent id. Reset here so the new row starts from zero. + resetPartialPersistAccumulator(state); deps.onEvent({ type: "assistant_turn_start", messageId: reserveResult.message.id, @@ -583,6 +712,10 @@ function handleTextDelta( messageId: state.lastAssistantMessageId, }); if (deps.shouldGenerateTitle) state.firstAssistantText += drained.emitText; + // Mirror the drained delta into state.currentMessageContent so partial + // flushes mid-turn see the same content the user is watching live. + appendTextToCurrentMessage(state, drained.emitText); + schedulePartialFlush(state, deps); } } @@ -1145,6 +1278,29 @@ export async function handleMessageComplete( // Reset per-turn tool tracking for the new turn. state.currentTurnToolUseIds = []; + // Cancel any pending debounced partial flush and await an already + // in-flight one before the authoritative `updateContent` below. + // Without the timer-clear, a timer that fires during this handler + // could double-write (idempotent in content but wastes a write) or + // race ahead of the indexer/projector and serve a stale snapshot. + // Without the await, a partial pipeline call that was dispatched a + // moment before this handler can settle AFTER the final write and + // overwrite the authoritative row. + if (state.pendingPartialFlushTimer !== undefined) { + clearTimeout(state.pendingPartialFlushTimer); + state.pendingPartialFlushTimer = undefined; + } + if (state.pendingPartialFlushPromise !== undefined) { + try { + await state.pendingPartialFlushPromise; + } catch { + // The partial flush swallows its own pipeline errors via + // `rlog.warn`; the `try`/`catch` here is defensive against + // future changes that might surface them. + } + state.pendingPartialFlushPromise = undefined; + } + // Flush any remaining directive display buffer if (state.pendingDirectiveDisplayBuffer.length > 0) { deps.onEvent({ @@ -1215,13 +1371,14 @@ export async function handleMessageComplete( state.pendingToolResults.clear(); } - // Clean assistant content and accumulate directives - const { - cleanedContent, - directives: msgDirectives, - warnings: msgWarnings, - } = cleanAssistantContent(event.message.content); - const cleanedBlocks = cleanedContent as ContentBlock[]; + // Accumulate directives + warnings from the assistant content for + // downstream attachment processing. `cleanAssistantContent` is also + // called inside {@link buildPersistedAssistantContent} below; running + // it here separately is the cheapest way to keep the directive + // side-effects local to this handler while letting the shared helper + // own the persisted-content shape. + const { directives: msgDirectives, warnings: msgWarnings } = + cleanAssistantContent(event.message.content); state.accumulatedDirectives.push(...msgDirectives); state.directiveWarnings.push(...msgWarnings); if (msgDirectives.length > 0) { @@ -1243,31 +1400,14 @@ export async function handleMessageComplete( // are applied in handleToolResult after all tools for the turn complete, // then the persisted message is updated via updateMessageContent. - // Build content with UI surfaces - const contentWithSurfaces: ContentBlock[] = [...cleanedBlocks]; - for (const surface of deps.ctx.currentTurnSurfaces) { - contentWithSurfaces.push({ - type: "ui_surface", - surfaceId: surface.surfaceId, - surfaceType: surface.surfaceType, - title: surface.title, - data: surface.data, - actions: surface.actions, - display: surface.display, - ...(surface.persistent ? { persistent: true } : {}), - } as unknown as ContentBlock); - } - - // Redact known-pattern secrets from assistant text blocks before they are - // written to durable storage. Non-text blocks (images, UI surfaces) pass - // through unchanged. The live model history retains the original values. - const contentForPersistence = contentWithSurfaces.map((block) => { - if (block.type === "text") { - const tb = block as Extract; - return { ...tb, text: redactSecrets(tb.text) }; - } - return block; - }); + // Build the canonical persisted content (cleaned + surfaces + + // redacted) via the shared helper. The partial-persist flush uses + // the same helper with `surfaces=[]` so a mid-turn snapshot lands in + // the same shape as the finalize. + const contentForPersistence = buildPersistedAssistantContent( + event.message.content as ContentBlock[], + deps.ctx.currentTurnSurfaces, + ); // The row was reserved at `llm_call_started` (with channel metadata // stamped at that point) and `state.lastAssistantMessageId` carries its @@ -1296,6 +1436,9 @@ export async function handleMessageComplete( DEFAULT_TIMEOUTS.persistence, ); state.assistantRowAwaitingFinalization = false; + // Reset the partial-persist mirror so subsequent calls in this turn + // start with an empty running view. + state.currentMessageContent = []; // ── Indexing + attention projection (restored from the pre-B3 `add` path) ── // `reserveMessage` + `updateMessageContent` are CRUD-only: they don't run @@ -1419,8 +1562,10 @@ export async function handleMessageComplete( deps.ctx.currentTurnSurfaces = []; - // Emit trace event - const charCount = cleanedBlocks + // Emit trace event. Char count is computed from the cleaned + + // redacted text blocks (UI surface blocks filtered out via the + // type guard) — same shape as what was just persisted. + const charCount = contentForPersistence .filter( (b): b is Extract => b.type === "text", ) diff --git a/assistant/src/daemon/conversation-agent-loop.ts b/assistant/src/daemon/conversation-agent-loop.ts index ad7a5be065f..9982108b188 100644 --- a/assistant/src/daemon/conversation-agent-loop.ts +++ b/assistant/src/daemon/conversation-agent-loop.ts @@ -484,6 +484,26 @@ function buildPluginTurnContext( // ── Context Interface ──────────────────────────────────────────────── +/** + * Per-surface entry tracked on the current turn. Inline shape kept stable so + * routes and persistence helpers can consume it via a named import instead of + * `infer`-extracting from {@link AgentLoopConversationContext}. + */ +export interface AssistantSurface { + surfaceId: string; + surfaceType: SurfaceType; + title?: string; + data: SurfaceData; + actions?: Array<{ + id: string; + label: string; + style?: string; + data?: Record; + }>; + display?: string; + persistent?: boolean; +} + export interface AgentLoopConversationContext { readonly conversationId: string; messages: Message[]; @@ -533,20 +553,7 @@ export interface AgentLoopConversationContext { pendingSurfaceActions: Map; surfaceActionRequestIds: Set; approvedViaPromptThisTurn?: boolean; - currentTurnSurfaces: Array<{ - surfaceId: string; - surfaceType: SurfaceType; - title?: string; - data: SurfaceData; - actions?: Array<{ - id: string; - label: string; - style?: string; - data?: Record; - }>; - display?: string; - persistent?: boolean; - }>; + currentTurnSurfaces: AssistantSurface[]; workingDir: string; workspaceTopLevelContext: string | null; diff --git a/assistant/src/daemon/conversation.ts b/assistant/src/daemon/conversation.ts index 1f4b2ac0d39..3fb8e59cbe0 100644 --- a/assistant/src/daemon/conversation.ts +++ b/assistant/src/daemon/conversation.ts @@ -74,6 +74,7 @@ import type { OnboardingContext } from "../types/onboarding-context.js"; import type { AbortReason } from "../util/abort-reasons.js"; import { getLogger } from "../util/logger.js"; import type { AssistantAttachmentDraft } from "./assistant-attachments.js"; +import type { AssistantSurface } from "./conversation-agent-loop.js"; import { applyCompactionResult, runAgentLoopImpl, @@ -327,20 +328,7 @@ export class Conversation { ReturnType >(); /** @internal */ withSurface = createSurfaceMutex(); - /** @internal */ currentTurnSurfaces: Array<{ - surfaceId: string; - surfaceType: SurfaceType; - title?: string; - data: SurfaceData; - actions?: Array<{ - id: string; - label: string; - style?: string; - data?: Record; - }>; - display?: string; - persistent?: boolean; - }> = []; + /** @internal */ currentTurnSurfaces: AssistantSurface[] = []; /** @internal */ workspaceTopLevelContext: string | null = null; /** @internal */ workspaceTopLevelDirty = true; /**