From ff4e35e999c732b4debdca79d261ee1fb9f5bbc1 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:17:05 +0000 Subject: [PATCH] feat(harness): distinguish prepared Assistant context from human tasks --- .changeset/assistant-prepared-context.md | 5 + packages/harness/docs/assistant-history.md | 7 + .../src/core/assistant-record-capture.ts | 16 +- packages/harness/src/core/assistant-record.ts | 10 +- .../assistant-continuation-seed.test.ts | 224 ++++++++++++++++++ .../src/shared/assistant-continuation.ts | 17 ++ packages/harness/src/shared/opencode-turn.ts | 61 ++++- .../web/src/components/OpenCodeChat.tsx | 76 +++++- .../web/src/lib/assistant-lifecycle-client.ts | 14 ++ 9 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 .changeset/assistant-prepared-context.md create mode 100644 packages/harness/src/shared/assistant-continuation-seed.test.ts diff --git a/.changeset/assistant-prepared-context.md b/.changeset/assistant-prepared-context.md new file mode 100644 index 000000000..ae81d71b9 --- /dev/null +++ b/.changeset/assistant-prepared-context.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Recognize verified continuation context without inventing an unfinished human task. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 5d5e1047b..617f1b7d5 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -117,3 +117,10 @@ accepted context. Every later explicit child Send includes the frozen recorded brief with freshly resolved current guidance. With no activation supplied, ordinary legacy delivery remains available, legacy inline history can preflight, and accepted-context Continue fails before reserving or allocating a child. + +A prepared child's exact seed is displayed as recorded background context. Its +attestation binds native conversation, message and part IDs, operation UUID, text +and content hash. Only that exact no-reply seed is excluded from human task status +and archive counts; arbitrary synthetic messages and unexpected answers retain +ordinary task semantics. Opening or reloading it does not trigger recovery. +Missing native history offers a separate action to review the retained record. diff --git a/packages/harness/src/core/assistant-record-capture.ts b/packages/harness/src/core/assistant-record-capture.ts index c6df08763..9ade78ae2 100644 --- a/packages/harness/src/core/assistant-record-capture.ts +++ b/packages/harness/src/core/assistant-record-capture.ts @@ -1,5 +1,7 @@ import type { HostedOpenCode } from "./opencode-host.js"; import type { AssistantRecordStore } from "./assistant-record-store.js"; +import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; +import { awaitAssistantInspection } from "./assistant-lifecycle.js"; import type { AssistantRecord, AssistantRecordBinding, @@ -67,6 +69,9 @@ export class AssistantRecordCapture { AssistantRecordStore, "read" | "write" | "reserve" >, + private readonly continuation?: ( + signal: AbortSignal, + ) => Promise, ) { this.binding = { harnessSessionId: hosted.harnessSessionId, @@ -142,8 +147,17 @@ export class AssistantRecordCapture { { signal }, ); const native = await readBoundedHistory(response, signal); + const continuation = this.continuation + ? await awaitAssistantInspection(this.continuation(signal), signal) + : null; if (!this.live()) return; - const next = projectAssistantRecord(native, this.binding, revision); + const next = projectAssistantRecord( + native, + this.binding, + revision, + undefined, + continuation, + ); const previous = await this.store.read(this.binding); const record = reconcileAssistantRecord(previous, next); signal.throwIfAborted(); diff --git a/packages/harness/src/core/assistant-record.ts b/packages/harness/src/core/assistant-record.ts index 736ab804f..7b781d238 100644 --- a/packages/harness/src/core/assistant-record.ts +++ b/packages/harness/src/core/assistant-record.ts @@ -17,6 +17,7 @@ import { openCodeVisibleParts, } from "../shared/opencode-completion.js"; import { + assistantTaskMessages, openCodeTurn, type OpenCodeTurnMessage, } from "../shared/opencode-turn.js"; @@ -229,6 +230,9 @@ export function projectAssistantRecord( binding: AssistantRecordBinding, revision: number, capturedAt = new Date().toISOString(), + continuation?: + | import("../shared/assistant-continuation.js").AssistantContinuationView + | null, ): AssistantRecord { try { binding = validateAssistantRecordBinding(binding); @@ -243,7 +247,7 @@ export function projectAssistantRecord( }; const seen = new Set(), partIds = new Set(); - const native = nativeMessages + const validated = nativeMessages .map((value) => { const envelope = object(value), info = object(envelope.info), @@ -280,6 +284,10 @@ export function projectAssistantRecord( Number(object(a.info.time).created) - Number(object(b.info.time).created), ); + const native = assistantTaskMessages( + validated as unknown as OpenCodeTurnMessage[], + continuation, + ) as unknown as typeof validated; const messages = native as unknown as OpenCodeTurnMessage[]; const tokens = openCodeCompletionTokens(messages); const turns = new Map(); diff --git a/packages/harness/src/shared/assistant-continuation-seed.test.ts b/packages/harness/src/shared/assistant-continuation-seed.test.ts new file mode 100644 index 000000000..4c8e689f9 --- /dev/null +++ b/packages/harness/src/shared/assistant-continuation-seed.test.ts @@ -0,0 +1,224 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + openCodeTurn, + isAssistantContinuationSeed, + type OpenCodeTurnMessage, +} from "./opencode-turn.js"; +import { + verifyAssistantContinuationView, + type AssistantContinuationView, +} from "./assistant-continuation.js"; +import { projectAssistantRecord } from "../core/assistant-record.js"; + +const text = "Recorded context: the previous draft was completed."; +const view: AssistantContinuationView = { + operationId: "11111111-1111-4111-8111-111111111111", + sourceSessionId: "source", + sourceRecordRevision: 7, + capturedAt: "2026-09-14T00:00:00.000Z", + retainedTurns: 1, + omittedTurns: 0, + seed: { + conversationId: "ses_child", + messageId: "msg_seed", + partId: "prt_seed", + text, + sha256: createHash("sha256").update(text).digest("hex"), + }, +}; +const seed = (): OpenCodeTurnMessage => ({ + info: { + id: "msg_seed", + sessionID: "ses_child", + role: "user", + time: { created: 1 }, + }, + parts: [ + { + id: "prt_seed", + messageID: "msg_seed", + sessionID: "ses_child", + type: "text", + synthetic: true, + ignored: false, + text, + metadata: { + sapiomContinuation: { + operationId: view.operationId, + briefHash: view.seed.sha256, + }, + }, + }, + ], +}); +const binding = { + harnessSessionId: "child", + cwd: "/workspace", + conversationId: "ses_child", + contextAuthorityScope: "a".repeat(64), +}; + +it("keeps an exact prepared seed ready with no recovery or archived human task", async () => { + expect(await verifyAssistantContinuationView(view, "ses_child")).toEqual( + view, + ); + expect(openCodeTurn([seed()], "idle", undefined, view)).toEqual({ + status: "ready", + }); + const record = projectAssistantRecord([seed()], binding, 1, undefined, view); + expect(record).toMatchObject({ + turns: [], + turnCount: 0, + messageCount: 0, + limitations: [], + }); + expect(openCodeTurn([seed()], "busy", undefined, view)).toEqual({ + status: "working", + }); +}); + +it.each([ + [ + "message identity", + (m: OpenCodeTurnMessage) => { + m.info!.id = "msg_other"; + }, + ], + [ + "native identity", + (m: OpenCodeTurnMessage) => { + m.info!.sessionID = "ses_other"; + }, + ], + [ + "part identity", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.id = "prt_other"; + }, + ], + [ + "part parent", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.messageID = "msg_other"; + }, + ], + [ + "part session", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.sessionID = "ses_other"; + }, + ], + [ + "ordinary text", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.synthetic = false; + }, + ], + [ + "ignored input", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.ignored = true; + }, + ], + [ + "changed brief", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.text = "new human request"; + }, + ], + [ + "missing marker", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.metadata = {}; + }, + ], + [ + "wrong operation", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.metadata!.sapiomContinuation = { + operationId: "other", + briefHash: view.seed.sha256, + }; + }, + ], + [ + "wrong hash", + (m: OpenCodeTurnMessage) => { + m.parts[0]!.metadata!.sapiomContinuation = { + operationId: view.operationId, + briefHash: "b".repeat(64), + }; + }, + ], + [ + "extra part", + (m: OpenCodeTurnMessage) => { + (m.parts as unknown[]).push({ type: "text", text: "user request" }); + }, + ], +] as const)( + "does not exempt a %s from normal task status", + (_label, mutate) => { + const message = seed(); + mutate(message); + expect(isAssistantContinuationSeed(message, view)).toBe(false); + expect(openCodeTurn([message], "idle", undefined, view)).toEqual({ + status: "failed", + }); + }, +); + +it("does not trust a synthetic marker without server attestation or a changed attested brief", async () => { + expect(openCodeTurn([seed()], "idle")).toEqual({ status: "failed" }); + expect(projectAssistantRecord([seed()], binding, 1).turnCount).toBe(1); + expect( + await verifyAssistantContinuationView({ + ...view, + seed: { ...view.seed, text: "changed" }, + }), + ).toBeNull(); + expect(await verifyAssistantContinuationView(view, "ses_other")).toBeNull(); +}); + +it("counts a later human task and preserves unexpected work attached to the seed", () => { + const user = seed(); + user.info!.id = "msg_human"; + Object.assign(user.parts[0]!, { + id: "prt_human", + messageID: "msg_human", + synthetic: false, + text: "Make the next change.", + }); + expect(openCodeTurn([seed(), user], "idle", undefined, view)).toEqual({ + status: "failed", + }); + expect( + projectAssistantRecord([seed(), user], binding, 1, undefined, view), + ).toMatchObject({ turnCount: 1, messageCount: 1 }); + const unexpected: OpenCodeTurnMessage = { + info: { + id: "msg_answer", + sessionID: "ses_child", + role: "assistant", + parentID: "msg_seed", + finish: "stop", + time: { created: 2, completed: 3 }, + }, + parts: [ + { + id: "prt_answer", + sessionID: "ses_child", + messageID: "msg_answer", + type: "text", + text: "Unexpected answer", + }, + ], + }; + expect(openCodeTurn([seed(), unexpected], "idle", undefined, view)).toEqual({ + status: "finished", + }); + expect( + projectAssistantRecord([seed(), unexpected], binding, 1, undefined, view), + ).toMatchObject({ turnCount: 1, messageCount: 2 }); +}); diff --git a/packages/harness/src/shared/assistant-continuation.ts b/packages/harness/src/shared/assistant-continuation.ts index b4fd0a899..1a9772b77 100644 --- a/packages/harness/src/shared/assistant-continuation.ts +++ b/packages/harness/src/shared/assistant-continuation.ts @@ -71,3 +71,20 @@ export function parseAssistantContinuationView( }, }; } + +/** Browser callers verify the exact bounded brief before adopting its attestation. */ +export async function verifyAssistantContinuationView( + value: unknown, + conversationId?: string, +): Promise { + const parsed = parseAssistantContinuationView(value, conversationId); + if (!parsed) return null; + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(parsed.seed.text), + ); + const hex = Array.from(new Uint8Array(hash), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return hex === parsed.seed.sha256 ? parsed : null; +} diff --git a/packages/harness/src/shared/opencode-turn.ts b/packages/harness/src/shared/opencode-turn.ts index c1b73d46a..5453f6eb7 100644 --- a/packages/harness/src/shared/opencode-turn.ts +++ b/packages/harness/src/shared/opencode-turn.ts @@ -3,11 +3,13 @@ import { openCodeVisibleText, parseOpenCodeCompletion, } from "./opencode-completion.js"; +import type { AssistantContinuationView } from "./assistant-continuation.js"; /** Use native messages: the UI adapter merges tool steps and final answers. */ export interface OpenCodeTurnMessage { info?: { id: string; + sessionID?: string; role: string; parentID?: string; agent?: string; @@ -18,19 +20,68 @@ export interface OpenCodeTurnMessage { time: { created?: number; completed?: number }; }; parts: readonly { + id?: string; + messageID?: string; + sessionID?: string; type: string; text?: string; ignored?: boolean; synthetic?: boolean; tool?: string; state?: { status?: string; input?: unknown }; - metadata?: { compaction_continue?: unknown }; + metadata?: { compaction_continue?: unknown; sapiomContinuation?: unknown }; }[]; } export const finalResponseAgent = "sapiom-final-response"; export const turnRecoveryAgent = "sapiom-turn-recovery"; +/** Only a server-attested exact no-reply seed is recorded input, not a human task. */ +export function isAssistantContinuationSeed( + message: OpenCodeTurnMessage | undefined, + continuation?: AssistantContinuationView | null, +): boolean { + if (!message || !continuation) return false; + const { seed, operationId } = continuation; + const part = message.parts[0]; + const raw = part?.metadata?.sapiomContinuation; + const marker = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : null; + return ( + message.info?.role === "user" && + message.info.id === seed.messageId && + message.info.sessionID === seed.conversationId && + message.parts.length === 1 && + part?.id === seed.partId && + part.messageID === seed.messageId && + part.sessionID === seed.conversationId && + part.type === "text" && + part.synthetic === true && + part.ignored === false && + part.text === seed.text && + marker?.operationId === operationId && + marker.briefHash === seed.sha256 + ); +} + +export function assistantTaskMessages( + messages: readonly T[], + continuation?: AssistantContinuationView | null, +): readonly T[] { + if (!continuation) return messages; + return messages.filter( + (message) => + !isAssistantContinuationSeed(message, continuation) || + messages.some( + (answer) => + answer.info?.role === "assistant" && + answer.info.parentID === message.info?.id, + ), + ); +} + export function openCodeResult( message: OpenCodeTurnMessage | undefined, token: string | undefined, @@ -59,13 +110,16 @@ export function openCodeTurn( status: string | undefined, /** A token resolved against full history when evaluating an isolated archived turn. */ completionToken?: string, + continuation?: AssistantContinuationView | null, ): { status: "ready" | "working" | "finished" | "stopped" | "failed" | "unknown"; missing?: string; } { if (!status) return { status: "unknown" }; if (status !== "idle") return { status: "working" }; - const newestFirst = [...messages].reverse(); + const newestFirst = [ + ...assistantTaskMessages(messages, continuation), + ].reverse(); const user = newestFirst.find((message) => message.info?.role === "user"); if (!user) return { status: "ready" }; // Older previews only summarized a stopped run; that did not finish its task. @@ -78,7 +132,8 @@ export function openCodeTurn( ); if (!answer?.info || !answer.info.time.completed) return { status: "failed" }; if (answer.info.agent === finalResponseAgent) return { status: "failed" }; - const token = completionToken ?? openCodeCompletionTokens(messages).get(user.info!.id); + const token = + completionToken ?? openCodeCompletionTokens(messages).get(user.info!.id); if (answer.info.error) return { status: "failed" }; if (token) { const result = openCodeResult(answer, token); diff --git a/packages/harness/web/src/components/OpenCodeChat.tsx b/packages/harness/web/src/components/OpenCodeChat.tsx index 533324b38..1eefdd048 100644 --- a/packages/harness/web/src/components/OpenCodeChat.tsx +++ b/packages/harness/web/src/components/OpenCodeChat.tsx @@ -33,6 +33,7 @@ import { finalResponseAgent, turnRecoveryAgent, openCodeTurn, + isAssistantContinuationSeed, openCodeResult, } from "../../../src/shared/opencode-turn"; import { @@ -40,6 +41,10 @@ import { openCodeVisibleParts, openCodeVisibleText, } from "../../../src/shared/opencode-completion"; +import { + verifyAssistantContinuationView, + type AssistantContinuationView, +} from "../../../src/shared/assistant-continuation"; import { openCodeTransportFailure, parseOpenCodeStudioErrorEvent, @@ -61,8 +66,10 @@ interface Props { onSignIn: () => void; onOpenSettings: () => void; onOpenTerminal: () => void; + onReviewHistory?: () => void; } interface RecoveryNotice { + code?: OpenCodeTransportFailure["code"]; message: string; action: OpenCodeTransportAction; } @@ -85,6 +92,7 @@ const requestError = reconnectNotice( /** The parser returns the shared table entry, never the server's string. */ const trustedNotice = (failure: OpenCodeTransportFailure): RecoveryNotice => ({ + code: failure.code, message: failure.message, action: failure.action, }); @@ -107,6 +115,7 @@ export function OpenCodeChat({ onSignIn, onOpenSettings, onOpenTerminal, + onReviewHistory, }: Props) { const [attachment, setAttachment] = useState( null, @@ -162,7 +171,14 @@ export function OpenCodeChat({ harnessSessionId, lifecycle.revision, ); - if (!attached) { + if ( + !attached || + (attached.continuation && + !(await verifyAssistantContinuationView( + attached.continuation, + attached.conversationId, + ))) + ) { if (!abort.signal.aborted) setError( trustedNotice(openCodeTransportFailure("lifecycle_changed")), @@ -188,12 +204,14 @@ export function OpenCodeChat({ conversationId={attachment.conversationId} lease={attachment.lease} initialExecution={attachment.lifecycle.execution} + continuation={attachment.continuation} selectedAgentPath={selectedAgentPath} retry={retry} draft={draft} onSignIn={onSignIn} onOpenSettings={onOpenSettings} onOpenTerminal={onOpenTerminal} + onReviewHistory={onReviewHistory} /> ) : (
@@ -204,6 +222,7 @@ export function OpenCodeChat({ onSignIn={onSignIn} onOpenSettings={onOpenSettings} onOpenTerminal={onOpenTerminal} + onReviewHistory={onReviewHistory} /> ) : ( Opening Assistant… @@ -219,11 +238,13 @@ function RuntimeChat({ conversationId, lease, initialExecution, + continuation, retry, draft, onSignIn, onOpenSettings, onOpenTerminal, + onReviewHistory, }: { baseUrl: string; selectedAgentPath?: string | null; @@ -231,11 +252,13 @@ function RuntimeChat({ conversationId: string; lease: string; initialExecution: AssistantAttachment["lifecycle"]["execution"]; + continuation?: AssistantContinuationView; retry: () => void; draft: ChatDraft; onSignIn: () => void; onOpenSettings: () => void; onOpenTerminal: () => void; + onReviewHistory?: () => void; }) { // Selection changes must not recreate the conversation/runtime or its draft. const selection = useRef(selectedAgentPath); @@ -378,6 +401,7 @@ function RuntimeChat({ conversationId={conversationId} lease={lease} execution={execution} + continuation={continuation} connected={connected} reconcile={reconcile} error={actionError ?? transportError} @@ -387,6 +411,7 @@ function RuntimeChat({ onSignIn={onSignIn} onOpenSettings={onOpenSettings} onOpenTerminal={onOpenTerminal} + onReviewHistory={onReviewHistory} onTypedError={onTypedError} /> @@ -418,6 +443,7 @@ function ChatSurface({ conversationId, lease, execution, + continuation, connected, reconcile, error, @@ -427,6 +453,7 @@ function ChatSurface({ onSignIn, onOpenSettings, onOpenTerminal, + onReviewHistory, onTypedError, }: { baseUrl: string; @@ -434,6 +461,7 @@ function ChatSurface({ conversationId: string; lease: string; execution: AssistantAttachment["lifecycle"]["execution"]; + continuation?: AssistantContinuationView; connected: boolean; reconcile: () => void; error: RecoveryNotice | null; @@ -443,6 +471,7 @@ function ChatSurface({ onSignIn: () => void; onOpenSettings: () => void; onOpenTerminal: () => void; + onReviewHistory?: () => void; onTypedError: (failure: OpenCodeTransportFailure) => void; }) { const loading = useAuiState((s) => s.thread.isLoading); @@ -471,6 +500,8 @@ function ChatSurface({ const turn = openCodeTurn( native.messageOrder.map((id) => native.messagesById[id]!).filter(Boolean), native.sessionStatus?.type, + undefined, + continuation, ); const attempted = useRef(new Set()); const recoveryAbort = useRef(null); @@ -553,6 +584,7 @@ function ChatSurface({ conversationId, lease, execution, + continuation, error, missing, pending, @@ -619,15 +651,37 @@ function ChatSurface({ scrollToBottomOnRunStart={false} >
- - - + {continuation && ( +
+ + Recorded context from a previous Assistant session + +

+ Prepared from {continuation.retainedTurns} saved turns.{" "} + {continuation.omittedTurns > 0 + ? `${continuation.omittedTurns} earlier turns were omitted. ` + : ""} + Send a message when you are ready to continue. +

+ +
+ )} + {!continuation && ( + + + + )} {({ message }) => { const nativeMessage = native.messagesById[message.id]; + if (isAssistantContinuationSeed(nativeMessage, continuation)) + return null; const token = nativeMessage?.info?.role === "assistant" ? completionTokens.get(nativeMessage.info.parentID) @@ -703,6 +757,7 @@ function ChatSurface({ onSignIn={onSignIn} onOpenSettings={onOpenSettings} onOpenTerminal={onOpenTerminal} + onReviewHistory={onReviewHistory} /> )}
@@ -765,12 +820,14 @@ function Recovery({ onSignIn, onOpenSettings, onOpenTerminal, + onReviewHistory, }: { notice: RecoveryNotice; retry: () => void; onSignIn: () => void; onOpenSettings: () => void; onOpenTerminal: () => void; + onReviewHistory?: () => void; }) { const action = { sign_in: { label: "Sign in", run: onSignIn }, @@ -791,6 +848,11 @@ function Recovery({ > {action[notice.action].label} + {notice.code === "native_history_missing" && onReviewHistory && ( + + )}
); } diff --git a/packages/harness/web/src/lib/assistant-lifecycle-client.ts b/packages/harness/web/src/lib/assistant-lifecycle-client.ts index 77acdee70..e10c70577 100644 --- a/packages/harness/web/src/lib/assistant-lifecycle-client.ts +++ b/packages/harness/web/src/lib/assistant-lifecycle-client.ts @@ -1,9 +1,14 @@ import type { AssistantLifecycle } from "../../../src/shared/assistant-session"; +import { + parseAssistantContinuationView, + type AssistantContinuationView, +} from "../../../src/shared/assistant-continuation"; export interface AssistantAttachment { conversationId: string; lease: string; lifecycle: AssistantLifecycle; + continuation?: AssistantContinuationView; } const object = (value: unknown): value is Record => @@ -44,8 +49,16 @@ export function parseAssistantAttachment( ): AssistantAttachment | null { if (!object(value)) return null; const lifecycle = parseAssistantLifecycle(value.lifecycle, harnessSessionId); + const continuation = + value.continuation == null + ? null + : parseAssistantContinuationView( + value.continuation, + value.conversationId as string, + ); if ( !lifecycle || + (value.continuation != null && !continuation) || lifecycle.lifecycle !== "open" || ![expectedRevision, expectedRevision + 1].includes(lifecycle.revision) || typeof value.conversationId !== "string" || @@ -60,5 +73,6 @@ export function parseAssistantAttachment( conversationId: value.conversationId, lease: value.lease, lifecycle, + ...(continuation ? { continuation } : {}), }; }