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;
/**