From 17460c6e237ae3d292329bad2cc1960e6f024021 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:09:20 +0000 Subject: [PATCH] feat(harness): share context owner across Assistant lifecycle consumers --- .changeset/assistant-context-consumer.md | 5 + packages/harness/docs/assistant-history.md | 8 + .../harness/src/core/opencode-host.test.ts | 90 ++- packages/harness/src/core/opencode-host.ts | 41 +- .../server/assistant-context-runtime.test.ts | 550 ++++++++++++++++++ .../src/server/assistant-context-runtime.ts | 148 +++++ .../harness/src/server/studio-assistant.ts | 49 +- 7 files changed, 865 insertions(+), 26 deletions(-) create mode 100644 .changeset/assistant-context-consumer.md create mode 100644 packages/harness/src/server/assistant-context-runtime.test.ts create mode 100644 packages/harness/src/server/assistant-context-runtime.ts diff --git a/.changeset/assistant-context-consumer.md b/.changeset/assistant-context-consumer.md new file mode 100644 index 000000000..c5456c341 --- /dev/null +++ b/.changeset/assistant-context-consumer.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Share the existing Assistant context owner across explicit lifecycle consumers. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index d1cd8ea1d..5d5e1047b 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -109,3 +109,11 @@ an operation UUID; Continue includes the displayed record revision. Responses project public session/lifecycle/provenance fields only. Continue retry storage uses an opaque server-projected scope stable across restarts and separated by authorized account, workspace, project, harness and native binding. + +An explicitly supplied verified context runtime activates one delivery consumer +for Send, recovery, Resume and Continue. It stores accepted sources beside the +native engine under the exact host scope; removing the engine does not remove +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. diff --git a/packages/harness/src/core/opencode-host.test.ts b/packages/harness/src/core/opencode-host.test.ts index bb946cee7..70138ce83 100644 --- a/packages/harness/src/core/opencode-host.test.ts +++ b/packages/harness/src/core/opencode-host.test.ts @@ -56,6 +56,7 @@ const getBrowserState = vi.fn(() => ({ let root: string; let cwd: string; let host: OpenCodeHost; +let hostOptions: ConstructorParameters[0]; let grant: AssistantGrant | null; let changed: () => void; let expectShutdownFailure = false; @@ -122,7 +123,7 @@ beforeEach(async () => { close, }); prepareSkills.mockReset().mockResolvedValue([]); - host = new OpenCodeHost({ + hostOptions = { createObserver, access: { get: () => grant, @@ -140,7 +141,8 @@ beforeEach(async () => { authorize, prepareSkills, start, - }); + }; + host = new OpenCodeHost(hostOptions); }); afterEach(async () => { vi.useRealTimers(); @@ -151,6 +153,82 @@ afterEach(async () => { }); describe("Studio-owned OpenCode lifecycle", () => { + it("uses only the activated owner's verified launcher and exact context scope", async () => { + await host.close(); + const legacy = vi.fn(), + assertAvailable = vi.fn(async () => {}); + host = new OpenCodeHost({ + ...hostOptions, + start: legacy, + contextRuntime: { start, assertAvailable }, + }); + const hosted = await host.ensure("studio-a"); + expect(assertAvailable).toHaveBeenCalledOnce(); + expect(legacy).not.toHaveBeenCalled(); + const options = start.mock.calls[0]![0]; + expect(options.assistantContext).toEqual({ + authorityScope: hosted.contextAuthorityScope, + }); + expect(options.stateRoot).toBe(join(hosted.stateRoot, "engine")); + expect(options.beforeLaunch).toBeTypeOf("function"); + expect(options.signal).toBe(hosted.signal); + expect(assertAvailable.mock.invocationCallOrder[0]).toBeLessThan( + issue.mock.invocationCallOrder[0]!, + ); + }); + it("refuses inactive context ownership before issuing credentials or launching native", async () => { + await host.close(); + const assertAvailable = vi.fn(async () => { + throw new OpenCodeTransportError( + openCodeTransportFailure("context_unavailable"), + ); + }); + host = new OpenCodeHost({ + ...hostOptions, + contextRuntime: { start, assertAvailable }, + }); + await expect(host.ensure("studio-a")).rejects.toMatchObject({ + failure: { code: "context_unavailable" }, + }); + expect(start).not.toHaveBeenCalled(); + expect(issue).not.toHaveBeenCalled(); + }); + it("End settles held context availability and its late result cannot launch over a replacement", async () => { + await host.close(); + let release!: () => void; + const assertAvailable = vi + .fn(async () => {}) + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + host = new OpenCodeHost({ + ...hostOptions, + contextRuntime: { start, assertAvailable }, + }); + const pending = host.ensure("studio-a"); + const rejected = expect(pending).rejects.toMatchObject({ + failure: { code: "transport_unavailable" }, + }); + await vi.waitFor(() => expect(assertAvailable).toHaveBeenCalledOnce()); + try { + expect(await host.retireWithResult("studio-a", 500)).toEqual({ + state: "confirmed", + }); + await rejected; + expect(start).not.toHaveBeenCalled(); + expect(issue).not.toHaveBeenCalled(); + const replacement = await host.ensure("studio-a"); + release(); + await Promise.resolve(); + expect(host.current("studio-a")).toBe(replacement); + expect(start).toHaveBeenCalledOnce(); + } finally { + release(); + } + }); it("keeps accepted scope stable across credential rotation and runtime restart", async () => { const original = await host.ensure("studio-a"); expect(original.contextAuthorityScope).toMatch(/^[a-f0-9]{64}$/); @@ -160,6 +238,7 @@ describe("Studio-owned OpenCode lifecycle", () => { }); expect(Object.isFrozen(original.model)).toBe(true); expect(Object.isFrozen(original)).toBe(true); + expect(start.mock.calls[0]![0]).not.toHaveProperty("assistantContext"); grant = { ...grant!, identityRevision: "new-revision", @@ -615,7 +694,12 @@ describe("Studio-owned OpenCode lifecycle", () => { it("a stale workspace authorization rejection cannot retire a replacement after End", async () => { let reject!: (reason: unknown) => void; - authorize.mockImplementationOnce(() => new Promise((_resolve, fail) => { reject = fail; })); + authorize.mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail; + }), + ); const stale = host.ensure("studio-a"); const rejected = expect(stale).rejects.toThrow("old authorization failed"); await host.retireWithResult("studio-a"); diff --git a/packages/harness/src/core/opencode-host.ts b/packages/harness/src/core/opencode-host.ts index 0058634ae..d2b8a521b 100644 --- a/packages/harness/src/core/opencode-host.ts +++ b/packages/harness/src/core/opencode-host.ts @@ -66,6 +66,12 @@ export interface HostedOpenCode extends OpenCodeWorkspace { export interface OpenCodeRetirement { state: "confirmed" | "absent" | "unconfirmed"; } +/** The context owner verifies its selected runtime/plugin before activation. + * Availability is read-only; the launcher retains normal process-guard ownership. */ +export interface OpenCodeContextRuntime { + readonly start: typeof startOpenCodeServer; + assertAvailable(): Promise; +} interface Managed { workspace: OpenCodeWorkspace; authority: string; @@ -92,6 +98,8 @@ interface Options { stateRoot: string, ) => Promise; start?: typeof startOpenCodeServer; + /** Explicit server capability; absent leaves the current production launcher unchanged. */ + contextRuntime?: OpenCodeContextRuntime; createObserver: ( hosted: HostedOpenCode, id: string, @@ -581,6 +589,30 @@ export class OpenCodeHost { let server: OpenCodeServer | undefined; let startupAttempted = false; try { + const contextRuntime = this.options.contextRuntime; + if (contextRuntime) { + if ( + typeof contextRuntime.start !== "function" || + typeof contextRuntime.assertAvailable !== "function" + ) + throw new OpenCodeTransportError( + openCodeTransportFailure("context_unavailable"), + ); + const signal = entry.abort.signal; + let abort!: () => void; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + // Cancellation can race this read-only capability check; it cannot launch native work. + await Promise.race([contextRuntime.assertAvailable(), cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } + await this.validate(entry); + } const release = await new DurableFileLock(join(stateRoot, "runtime"), { timeoutMs: 1000, processGuard: "required", @@ -631,12 +663,19 @@ export class OpenCodeHost { ...(skillPaths.length ? { skills: { paths: skillPaths } } : {}), }; startupAttempted = true; - server = await (this.options.start ?? startOpenCodeServer)({ + server = await ( + contextRuntime?.start ?? + this.options.start ?? + startOpenCodeServer + )({ cwd: entry.workspace.cwd, stateRoot: join(stateRoot, "engine"), config, signal: entry.abort.signal, beforeLaunch: (identity) => release.protectProcess(identity), + ...(contextRuntime + ? { assistantContext: { authorityScope: contextScope } } + : {}), }); void server.exited.then(() => this.retireExited(entry)).catch(() => {}); await this.validate(entry); diff --git a/packages/harness/src/server/assistant-context-runtime.test.ts b/packages/harness/src/server/assistant-context-runtime.test.ts new file mode 100644 index 000000000..1d37e6221 --- /dev/null +++ b/packages/harness/src/server/assistant-context-runtime.test.ts @@ -0,0 +1,550 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { ResolvedEnvironment } from "@sapiom/mcp/auth"; +import { + parseStudioAssistantSystem, + type AcceptedAssistantContext, +} from "@sapiom/opencode"; +import { + createAssistantContextRuntime, + type ActivatedAssistantContextRuntime, +} from "./assistant-context-runtime.js"; +import { composeAssistantPrompt } from "../core/studio-assistant-context.js"; +import { + AssistantContinuationStore, + freezeContinuationCandidate, +} from "../core/assistant-continuation-store.js"; +import { projectAssistantRecord } from "../core/assistant-record.js"; +import { + retainAssistantGuidance, + createAssistantSource, + encodeAssistantSkillPackage, +} from "../core/assistant-sources.js"; +import type { HostedOpenCode } from "../core/opencode-host.js"; +import type { HarnessSession } from "../shared/types.js"; +import type { StudioProjectIdentity } from "../core/studio-project-catalog.js"; + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "context-runtime-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +async function fixture() { + const cwd = join(root, "project"), + stateRoot = join(root, "opencode", "c".repeat(64)); + await mkdir(cwd); + await mkdir(join(stateRoot, "engine"), { recursive: true }); + const binding = { + harnessSessionId: "parent", + contextAuthorityScope: "a".repeat(64), + conversationId: "ses_parent", + cwd, + }; + const record = projectAssistantRecord( + [ + { + info: { + id: "msg_parent", + sessionID: "ses_parent", + role: "user", + time: { created: 1 }, + }, + parts: [ + { + id: "prt_parent", + messageID: "msg_parent", + sessionID: "ses_parent", + type: "text", + text: "The prior result is complete. Wait for a new task.", + }, + ], + }, + ], + binding, + 1, + ); + const store = new AssistantContinuationStore(root); + let receipt = await store.reserve( + binding, + 1, + 0, + randomUUID(), + async () => record, + ); + for (const phase of ["allocated", "creating"] as const) + receipt = await store.update(receipt, { phase }); + receipt = await store.update(receipt, { + phase: "associated", + childBinding: { + ...binding, + harnessSessionId: receipt.childStudioId, + conversationId: "ses_child", + contextAuthorityScope: "b".repeat(64), + }, + }); + const abort = new AbortController(); + const session: HarnessSession = { + id: receipt.childStudioId, + harness: "claude-code", + cwd, + title: "Child", + agentSessionId: null, + status: "exited", + terminalState: "not-started", + ready: false, + createdAt: "2026-09-14T00:00:00.000Z", + lastActiveAt: "2026-09-14T00:00:00.000Z", + exitCode: null, + boundWorkflowPath: null, + rehydratedFrom: null, + agentMapIdentity: { + sessionId: receipt.childStudioId, + projectId: "project", + userId: "user", + }, + }; + const project: StudioProjectIdentity = { + projectId: "project", + identityVersion: 1, + displayName: "Project", + rootBindings: [ + { id: "root", repositoryId: null, localRootRef: cwd, status: "active" }, + ], + legacyWorkspaceKeys: [], + createdAt: "2026-09-14", + updatedAt: "2026-09-14", + }; + const hosted: HostedOpenCode = { + harnessSessionId: session.id, + cwd, + stateRoot, + contextAuthorityScope: "b".repeat(64), + model: { providerID: "sapiom", modelID: "test" }, + signal: abort.signal, + isCurrent: () => !abort.signal.aborted, + server: { + pid: 1, + exited: new Promise(() => {}), + close: vi.fn(), + fetch: vi.fn(), + fetchJson: vi.fn().mockResolvedValue({ sapiom: { status: "connected" } }), + }, + }; + const loadSystemPrompt = vi.fn(async () => "ORIGINAL_PROFILE_BYTES"); + const contextOptions = { + getSession: () => session, + getWorkflows: vi.fn(async () => []), + resolveProject: vi.fn(async () => project), + getEnvironment: () => ({ name: "dev" }) as ResolvedEnvironment, + loadSystemPrompt, + }; + const readChild = vi.fn((id: string) => store.readChild(id)); + const assertCurrent = vi.fn(async () => { + hosted.signal.throwIfAborted(); + }); + const activated: ActivatedAssistantContextRuntime = { + start: vi.fn(async () => hosted.server), + assertAvailable: vi.fn(async () => {}), + }; + const build = ( + owner: ActivatedAssistantContextRuntime | undefined = activated, + ) => + createAssistantContextRuntime({ + activated: owner, + contextOptions, + assertCurrent, + continuations: { readChild }, + }); + const runtime = build(); + const compose = (accepted: AcceptedAssistantContext) => + runtime.delivery.compose( + hosted, + "ses_child", + accepted, + { attemptToken: randomUUID() }, + abort.signal, + ); + return { + hosted, + session, + project, + store, + runtime, + activated, + build, + abort, + readChild, + loadSystemPrompt, + contextOptions, + compose, + receipt: () => receipt, + accept: () => + runtime.delivery.accept(hosted, "ses_child", null, abort.signal), + prepare: async () => { + const candidate = await runtime.resolveCandidate( + hosted, + null, + abort.signal, + ); + receipt = await store.update(receipt, { + phase: "accepting", + frozenCandidate: freezeContinuationCandidate( + candidate, + receipt.childBinding!, + receipt.acceptanceId, + ), + }); + const accepted = await runtime.delivery.acceptFrozen( + hosted, + "ses_child", + candidate, + receipt.acceptanceId, + abort.signal, + ); + const ref = { + schemaVersion: accepted.schemaVersion, + acceptanceId: accepted.acceptanceId, + authorityScope: accepted.authorityScope, + conversationId: accepted.conversationId, + revision: accepted.revision, + }; + receipt = await store.update(receipt, { + acceptedRef: ref, + phase: "seeding", + }); + receipt = await store.update(receipt, { phase: "prepared" }); + return accepted; + }, + }; +} +const wire = (system: string) => { + const parsed = parseStudioAssistantSystem(system); + if (parsed.kind !== "accepted-v2") + throw new Error("Expected accepted context"); + return parsed.wire; +}; + +it("keeps legacy dispatch without activation, supports exact inline Resume and refuses accepted execution", async () => { + const f = await fixture(), + runtime = f.runtime; + const inactive = createAssistantContextRuntime({ + contextOptions: f.contextOptions, + assertCurrent: async () => {}, + continuations: { readChild: f.readChild }, + }); + expect(typeof inactive.context).toBe("function"); + await expect(inactive.assertAvailable()).rejects.toMatchObject({ + failure: { code: "context_unavailable" }, + }); + await expect( + inactive.resolveCandidate(f.hosted, null, f.abort.signal), + ).rejects.toThrow("context"); + expect(f.loadSystemPrompt).not.toHaveBeenCalled(); + expect(f.readChild).not.toHaveBeenCalled(); + if (typeof inactive.context !== "function") + throw new Error("Expected legacy resolver"); + const saved = composeAssistantPrompt( + await inactive.context(f.hosted, null, f.abort.signal), + ).system; + const accepted = await runtime.delivery.accept( + f.hosted, + "ses_child", + null, + f.abort.signal, + ); + const typed = await f.compose(accepted); + f.loadSystemPrompt.mockRejectedValue( + new Error("Current profile unavailable"), + ); + const recovered = await inactive.delivery.recover( + f.hosted, + "ses_child", + saved, + f.abort.signal, + ); + const parsed = parseStudioAssistantSystem(recovered.system); + expect(parsed.kind).toBe("legacy-inline-v1"); + expect(recovered.system).toContain("ORIGINAL_PROFILE_BYTES"); + await expect( + inactive.delivery.recover( + f.hosted, + "ses_child", + typed.system, + f.abort.signal, + ), + ).rejects.toMatchObject({ failure: { code: "context_unavailable" } }); + expect(f.hosted.server.fetch).not.toHaveBeenCalled(); +}); + +it("shares one delivery, preserves default missing-loader facts, and writes the approved scoped owner path", async () => { + const f = await fixture(); + expect(f.runtime.context).toBe(f.runtime.delivery); + const accepted = await f.accept(); + expect(accepted.context.session.id).toBe(f.receipt().childStudioId); + const sources = accepted.instructionSet.sources; + expect( + sources + .filter((source) => source.status === "not-configured") + .map((source) => source.id), + ).toEqual(["project-instructions", "sapiom-agent-authoring"]); + const continuation = sources.find( + (source) => source.kind === "continuation", + )!; + expect(continuation).toMatchObject({ + authorityScope: f.hosted.contextAuthorityScope, + contentHash: f.receipt().brief.sha256, + required: true, + }); + expect( + sources.every( + (source) => source.authorityScope === f.hosted.contextAuthorityScope, + ), + ).toBe(true); + const retained = join( + f.hosted.stateRoot, + "assistant-context", + "v1", + f.hosted.contextAuthorityScope, + ); + expect( + await readFile(join(retained, "objects", f.receipt().brief.sha256), "utf8"), + ).toBe(f.receipt().brief.text); + await expect(stat(join(root, "assistant-context"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect( + wire((await f.compose(accepted)).system).stable.guidance.some( + (source) => source.text === f.receipt().brief.text, + ), + ).toBe(true); +}); + +it("adds the frozen child brief to every later Send while ordinary guidance and acceptance advance", async () => { + const f = await fixture(), + prepared = await f.prepare(); + const first = wire((await f.compose(prepared)).system); + f.loadSystemPrompt.mockResolvedValue("UPDATED_CURRENT_PROFILE"); + const next = await f.accept(), + sent = wire((await f.compose(next)).system); + expect(next.acceptanceId).not.toBe(prepared.acceptanceId); + expect(sent.context.session.id).toBe(f.receipt().childStudioId); + expect( + sent.stable.guidance.some( + (source) => source.text === f.receipt().brief.text, + ), + ).toBe(true); + expect(JSON.stringify(sent)).toContain("UPDATED_CURRENT_PROFILE"); + expect(JSON.stringify(first)).toContain("ORIGINAL_PROFILE_BYTES"); + const brief = (value: typeof first) => + value.stable.sourceManifest.sources.find( + (source) => source.kind === "continuation", + ); + expect(brief(sent)).toEqual(brief(first)); + expect( + f.readChild.mock.calls.every(([id]) => id === f.receipt().childStudioId), + ).toBe(true); +}); + +it("leaves ordinary sessions without continuation guidance", async () => { + const f = await fixture(); + f.readChild.mockResolvedValue(null); + const accepted = await f.accept(); + expect( + accepted.instructionSet.sources.some( + (source) => source.kind === "continuation", + ), + ).toBe(false); + expect( + accepted.instructionSet.sources.filter( + (source) => source.status === "not-configured", + ), + ).toHaveLength(2); +}); + +it("recovers through the source owner after engine loss and refuses missing committed source without live fallback", async () => { + const f = await fixture(), + accepted = await f.prepare(); + const original = wire((await f.compose(accepted)).system), + saved = (await f.compose(accepted)).system; + await rm(join(f.hosted.stateRoot, "engine"), { recursive: true }); + f.loadSystemPrompt.mockRejectedValue(new Error("Live provider must not run")); + f.readChild.mockClear(); + const restarted = f.build(); + expect( + wire( + ( + await restarted.delivery.recover( + f.hosted, + "ses_child", + saved, + f.abort.signal, + ) + ).system, + ).accepted, + ).toEqual(original.accepted); + expect(f.readChild).not.toHaveBeenCalled(); + await rm( + join( + f.hosted.stateRoot, + "assistant-context", + "v1", + f.hosted.contextAuthorityScope, + "objects", + f.receipt().brief.sha256, + ), + ); + await expect( + restarted.delivery.recover(f.hosted, "ses_child", saved, f.abort.signal), + ).rejects.toThrow("context"); + expect(f.readChild).not.toHaveBeenCalled(); + expect(f.hosted.server.fetch).not.toHaveBeenCalled(); +}); + +it("preserves trusted instruction/capability loaders and appends only the child-scoped continuation", async () => { + const f = await fixture(); + const owner = { + ...f.activated, + loadCapabilities: vi.fn(async () => [ + { name: "verified", status: "available" as const, tools: ["read"] }, + ]), + loadGuidance: vi.fn(async (_context, hosted: HostedOpenCode) => [ + retainAssistantGuidance( + { + id: "instructions", + kind: "project", + required: true, + source: "trusted:project", + revision: null, + status: "available", + text: "EXACT_OWNER_INSTRUCTION\r\n", + }, + hosted.contextAuthorityScope, + ), + ]), + }; + const runtime = f.build(owner), + accepted = await runtime.delivery.accept( + f.hosted, + "ses_child", + null, + f.abort.signal, + ); + expect(accepted.context.capabilities).toEqual([ + { name: "verified", status: "available", tools: ["read"] }, + ]); + expect(owner.loadGuidance.mock.calls[0]![0]).toMatchObject({ + session: { id: f.hosted.harnessSessionId, projectId: "project" }, + }); + const system = wire( + ( + await runtime.delivery.compose( + f.hosted, + "ses_child", + accepted, + { attemptToken: randomUUID() }, + f.abort.signal, + ) + ).system, + ); + expect( + system.stable.guidance.some( + (source) => source.text === "EXACT_OWNER_INSTRUCTION\r\n", + ), + ).toBe(true); + expect( + system.stable.guidance.some( + (source) => source.text === f.receipt().brief.text, + ), + ).toBe(true); +}); + +it("does not claim accepted skills are executable without their managed generation", async () => { + const f = await fixture(); + const metadata = { + id: "package", + kind: "skill" as const, + source: "trusted:package", + required: true, + status: "available" as const, + revision: null, + }; + const skill = createAssistantSource( + { ...metadata, authorityScope: f.hosted.contextAuthorityScope }, + { + format: "skill-package", + bytes: encodeAssistantSkillPackage([ + { + path: "SKILL.md", + executable: false, + bytes: Buffer.from("# Owned package"), + }, + ]), + }, + ); + const runtime = f.build({ + ...f.activated, + loadGuidance: async () => [{ metadata, ...skill }], + }); + const accepted = await runtime.delivery.accept( + f.hosted, + "ses_child", + null, + f.abort.signal, + ); + await expect( + runtime.delivery.compose( + f.hosted, + "ses_child", + accepted, + { attemptToken: randomUUID() }, + f.abort.signal, + ), + ).rejects.toThrow("context"); + expect(f.hosted.server.fetch).not.toHaveBeenCalled(); +}); + +it.each(["scope", "child", "hash", "unassociated"])( + "fails closed on %s mismatch in trusted continuation lookup", + async (change) => { + const f = await fixture(), + receipt = structuredClone(f.receipt()); + if (change === "scope") + receipt.childBinding!.contextAuthorityScope = "d".repeat(64); + if (change === "child") + Object.assign(receipt, { childStudioId: randomUUID() }); + if (change === "hash") receipt.brief.text += "changed"; + if (change === "unassociated") + Object.assign(receipt, { childBinding: null }); + f.readChild.mockResolvedValue(receipt); + await expect(f.accept()).rejects.toThrow("context"); + await expect( + stat(join(f.hosted.stateRoot, "assistant-context")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it("does not read current child guidance during a cancelled activation check", async () => { + const f = await fixture(); + let release!: () => void; + vi.mocked(f.activated.assertAvailable).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const pending = f.runtime.resolveCandidate(f.hosted, null, f.abort.signal); + const rejected = expect(pending).rejects.toThrow("ended"); + await vi.waitFor(() => + expect(f.activated.assertAvailable).toHaveBeenCalled(), + ); + f.abort.abort(new Error("ended")); + await rejected; + release(); + expect(f.loadSystemPrompt).not.toHaveBeenCalled(); + expect(f.readChild).not.toHaveBeenCalled(); +}); diff --git a/packages/harness/src/server/assistant-context-runtime.ts b/packages/harness/src/server/assistant-context-runtime.ts new file mode 100644 index 000000000..a6004e4ce --- /dev/null +++ b/packages/harness/src/server/assistant-context-runtime.ts @@ -0,0 +1,148 @@ +import { assistantContentHash } from "@sapiom/opencode"; +import type { + HostedOpenCode, + OpenCodeContextRuntime, +} from "../core/opencode-host.js"; +import { FileAssistantSourceStore } from "../core/assistant-source-store.js"; +import { assertAssistantRuntimeReady } from "../core/assistant-runtime-readiness.js"; +import { + createAssistantContextDelivery, + type AssistantContextDelivery, + type ResolveAssistantCandidate, +} from "../core/studio-assistant-delivery.js"; +import { + assistantContextUnavailable, + type ResolveAssistantContext, +} from "../core/studio-assistant-context.js"; +import { retainAssistantGuidance } from "../core/assistant-sources.js"; +import type { AssistantContinuationStore } from "../core/assistant-continuation-store.js"; +import { awaitAssistantInspection } from "../core/assistant-lifecycle.js"; +import { + createAssistantContextResolver, + createAssistantContextCandidateResolver, + defaultAssistantGuidance, +} from "./studio-assistant.js"; + +type ContextOptions = Parameters[0]; +type CandidateOptions = Parameters< + typeof createAssistantContextCandidateResolver +>[0]; + +/** Server-owned, verified activation. No CLI/browser flag or publication is added here. */ +export interface ActivatedAssistantContextRuntime extends OpenCodeContextRuntime { + readonly loadCapabilities?: ContextOptions["loadCapabilities"]; + readonly loadGuidance?: CandidateOptions["loadGuidance"]; +} +interface Options { + activated?: ActivatedAssistantContextRuntime; + contextOptions: Omit; + assertCurrent: (hosted: HostedOpenCode) => Promise; + continuations: Pick; +} +export interface AssistantContextRuntimeConsumer { + /** Pass this exact value to the ordinary router; its delivery also owns recovery. */ + context: ResolveAssistantContext | AssistantContextDelivery; + delivery: AssistantContextDelivery; + resolveCandidate: ResolveAssistantCandidate; + assertAvailable(): Promise; +} +const unavailable = (): never => { + throw assistantContextUnavailable(); +}; + +/** One shared source/delivery owner for Send, recovery, Resume and recorded Continue. */ +export function createAssistantContextRuntime( + options: Options, +): AssistantContextRuntimeConsumer { + const { activated } = options; + const assertAvailable = async () => { + if ( + !activated || + typeof activated.start !== "function" || + typeof activated.assertAvailable !== "function" + ) + return unavailable(); + await activated.assertAvailable(); + }; + const acquire = activated + ? createAssistantContextCandidateResolver({ + ...options.contextOptions, + loadCapabilities: activated.loadCapabilities, + loadGuidance: async (context, hosted, signal) => { + const sources = activated.loadGuidance + ? await activated.loadGuidance(context, hosted, signal) + : defaultAssistantGuidance().map((source) => + retainAssistantGuidance(source, hosted.contextAuthorityScope), + ); + signal.throwIfAborted(); + const receipt = await awaitAssistantInspection( + options.continuations.readChild(hosted.harnessSessionId), + signal, + ); + signal.throwIfAborted(); + if (!receipt) return sources; + const binding = receipt.childBinding; + if ( + !binding || + receipt.childStudioId !== hosted.harnessSessionId || + binding.harnessSessionId !== hosted.harnessSessionId || + binding.cwd !== hosted.cwd || + binding.contextAuthorityScope !== hosted.contextAuthorityScope || + assistantContentHash(receipt.brief.text) !== receipt.brief.sha256 || + sources.some((source) => source.version.kind === "continuation") + ) + unavailable(); + // The receipt contributes immutable quoted input, never a parent's accepted ref. + return [ + ...sources, + retainAssistantGuidance( + { + id: "studio-recorded-continuation", + kind: "continuation", + required: true, + source: `studio:${receipt.sourceBinding.harnessSessionId}/record/${receipt.sourceRecordRevision}`, + revision: receipt.brief.sha256, + status: "available", + text: receipt.brief.text, + }, + hosted.contextAuthorityScope, + ), + ]; + }, + }) + : undefined; + const resolveCandidate: ResolveAssistantCandidate = async ( + hosted, + selection, + signal, + ) => { + signal = AbortSignal.any([signal, hosted.signal]); + signal.throwIfAborted(); + hosted.signal.throwIfAborted(); + await awaitAssistantInspection(assertAvailable(), signal); + signal.throwIfAborted(); + hosted.signal.throwIfAborted(); + return acquire!(hosted, selection, signal); + }; + const delivery = createAssistantContextDelivery({ + resolveContext: resolveCandidate, + storeFor: activated + ? (hosted) => + new FileAssistantSourceStore( + hosted.stateRoot, + hosted.contextAuthorityScope, + ) + : unavailable, + assertCurrent: options.assertCurrent, + prepareRuntime: async (_hosted, retained) => + assertAssistantRuntimeReady(retained), + }); + return { + context: activated + ? delivery + : createAssistantContextResolver(options.contextOptions), + delivery, + resolveCandidate, + assertAvailable, + }; +} diff --git a/packages/harness/src/server/studio-assistant.ts b/packages/harness/src/server/studio-assistant.ts index 1bf775a55..1e86b6275 100644 --- a/packages/harness/src/server/studio-assistant.ts +++ b/packages/harness/src/server/studio-assistant.ts @@ -43,6 +43,32 @@ interface Options { ) => Promise; } +/** Preserve explicit missing-loader facts when another trusted source is appended. */ +export function defaultAssistantGuidance(): AssistantGuidance[] { + return [ + { + id: "project-instructions", + kind: "project", + required: false, + source: "host", + revision: null, + status: "not-configured", + reason: + "The trusted project instruction loader is not connected yet. Inspect applicable AGENTS.md (or CLAUDE.md fallback) before editing files.", + }, + { + id: "sapiom-agent-authoring", + kind: "skill", + required: false, + source: "bundled", + revision: null, + status: "not-configured", + reason: + "The managed authoring skill loader is not connected yet. Use the installed SDK documentation; do not claim this skill was loaded.", + }, + ]; +} + async function currentCapabilities( hosted: HostedOpenCode, signal: AbortSignal, @@ -148,28 +174,7 @@ export function createAssistantContextResolver( signal.throwIfAborted(); const sources = options.loadGuidance ? await options.loadGuidance(context, hosted, signal) - : [ - { - id: "project-instructions", - kind: "project" as const, - required: false, - source: "host", - revision: null, - status: "not-configured" as const, - reason: - "The trusted project instruction loader is not connected yet. Inspect applicable AGENTS.md (or CLAUDE.md fallback) before editing files.", - }, - { - id: "sapiom-agent-authoring", - kind: "skill" as const, - required: false, - source: "bundled", - revision: null, - status: "not-configured" as const, - reason: - "The managed authoring skill loader is not connected yet. Use the installed SDK documentation; do not claim this skill was loaded.", - }, - ]; + : defaultAssistantGuidance(); if ( JSON.stringify(await projectAuthority()) !== JSON.stringify(authority) || !hosted.isCurrent() ||