diff --git a/.changeset/tangle-native-session-events.md b/.changeset/tangle-native-session-events.md new file mode 100644 index 0000000..8c17412 --- /dev/null +++ b/.changeset/tangle-native-session-events.md @@ -0,0 +1,6 @@ +--- +"@tangle-network/agent-provider-tangle": patch +--- + +Accept harness-native session identifiers on execution-bound session update events. +Normalize absent optional Sandbox result fields before strict JSON validation. diff --git a/packages/agent-provider-tangle/src/leaf-modules.test.ts b/packages/agent-provider-tangle/src/leaf-modules.test.ts index 0c10165..ea028ea 100644 --- a/packages/agent-provider-tangle/src/leaf-modules.test.ts +++ b/packages/agent-provider-tangle/src/leaf-modules.test.ts @@ -351,9 +351,77 @@ describe("Tangle split leaf modules", () => { { type: "status", data: { executionId: "wrong", sessionId: "session-1" } } as never, { executionId: "execution-1", sessionId: "session-1" }, )).toThrow(/executionId/); + const nativeSessionEvent = environmentEventFromSandboxEvent( + { + type: "session.updated", + id: "event-native-session", + data: { + sessionId: "opencode-native-session", + sessionID: "opencode-native-session", + title: "Native harness session", + }, + } as never, + { + executionId: "execution-1", + sessionId: "runtime-session-1", + streamBound: true, + }, + ); + expect(nativeSessionEvent.normalized).toEqual({ + type: "session.updated", + sessionId: "opencode-native-session", + title: "Native harness session", + }); + expect(() => environmentEventFromSandboxEvent( + { + type: "session.updated", + data: { + executionId: "execution-other", + sessionId: "opencode-native-session", + }, + } as never, + { + executionId: "execution-1", + sessionId: "runtime-session-1", + streamBound: true, + }, + )).toThrow(/executionId/); + expect(() => environmentEventFromSandboxEvent( + { + type: "execution.started", + data: { + executionId: "execution-other", + sessionId: "runtime-session-1", + }, + } as never, + { + executionId: "execution-1", + sessionId: "runtime-session-1", + streamBound: true, + }, + )).toThrow(/executionId/); expect(tokenUsageFromData({ usage: { inputTokens: 2, outputTokens: 3 } })).toEqual({ inputTokens: 2, outputTokens: 3 }); expect(execResultFromSandboxExecResult({ exitCode: 0, stdout: "ok", stderr: "" } as never)).toEqual({ exitCode: 0, stdout: "ok", stderr: "" }); expect(validatedSandboxPromptResult(promptResult())).toMatchObject({ success: true, status: "success" }); + expect(validatedSandboxPromptResult({ + success: true, + status: "success", + durationMs: 1, + executionId: "execution-1", + response: "done", + error: undefined, + approval: undefined, + question: undefined, + plan: undefined, + traceId: undefined, + usage: undefined, + costUsd: undefined, + toolInvocations: undefined, + } as never)).toEqual(promptResult()); + expect(() => validatedSandboxPromptResult({ + ...(promptResult() as unknown as Record), + unsupported: undefined, + } as never)).toThrow(/JSON bound/); expect(agentTurnResultFromPromptRecord(validatedSandboxPromptResult(promptResult()), { sessionId: "session-1" })).toMatchObject({ text: "done", success: true }); const semanticInput = { prompt: "hello", turnId: "turn-1" } as never; const baseRequestDigest = sessionPromptRequestDigest( diff --git a/packages/agent-provider-tangle/src/retained-control.test.ts b/packages/agent-provider-tangle/src/retained-control.test.ts index ecae18b..5daa137 100644 --- a/packages/agent-provider-tangle/src/retained-control.test.ts +++ b/packages/agent-provider-tangle/src/retained-control.test.ts @@ -952,6 +952,15 @@ describe("Tangle retained control", () => { executionId: undefined, }, }, + { + id: "event-native-session", + type: "session.updated", + data: { + sessionId: "opencode-native-session", + sessionID: "opencode-native-session", + title: "Native harness session", + }, + }, { id: "event-2", type: "result", @@ -984,7 +993,7 @@ describe("Tangle retained control", () => { } as SandboxEvent; const replayEvents = options?.since === "event-1" - ? [upstreamEvents[1]!, upstreamEvents[1]!] + ? [upstreamEvents[1]!, upstreamEvents[2]!, upstreamEvents[2]!] : upstreamEvents; for (const event of replayEvents) yield event; }, @@ -1022,7 +1031,7 @@ describe("Tangle retained control", () => { } as SandboxEvent; const replayEvents = options?.lastEventId === "event-1" - ? [upstreamEvents[1]!, upstreamEvents[1]!] + ? [upstreamEvents[1]!, upstreamEvents[2]!, upstreamEvents[2]!] : upstreamEvents; for (const event of replayEvents) yield event; }, @@ -1057,7 +1066,15 @@ describe("Tangle retained control", () => { }); const replay = await collect(session.events({ since: "event-1" })); - expect(replay.map((event) => event.id)).toEqual(["event-2"]); + expect(replay.map((event) => event.id)).toEqual([ + "event-native-session", + "event-2", + ]); + expect(replay[0]?.normalized).toEqual({ + type: "session.updated", + sessionId: "opencode-native-session", + title: "Native harness session", + }); expect(capturedOptions).toMatchObject({ lastEventId: "event-1", executionId, diff --git a/packages/agent-provider-tangle/src/tangle-events.ts b/packages/agent-provider-tangle/src/tangle-events.ts index 0600229..1507a72 100644 --- a/packages/agent-provider-tangle/src/tangle-events.ts +++ b/packages/agent-provider-tangle/src/tangle-events.ts @@ -107,8 +107,16 @@ export function environmentEventFromSandboxEvent( "Tangle Sandbox emitted an unsolicited context transfer receipt", ); } - const { executionId: eventExecutionId, sessionId: eventSessionId } = - sandboxEventIdentity(event); + // An exact run stream is already selected by the runtime execution id. + // On that stream, session.updated carries the harness-native session id + // (for example an OpenCode session), not the runtime session id. Keep that + // value as event content while lifecycle frames remain identity-checked. + const identity = sandboxEventIdentity(event); + const eventExecutionId = identity.executionId; + const eventSessionId = + expected.streamBound === true && record.type === "session.updated" + ? undefined + : identity.sessionId; if ( expected.executionId !== undefined && ((eventExecutionId === undefined && expected.streamBound !== true) || diff --git a/packages/agent-provider-tangle/src/tangle-prompt.ts b/packages/agent-provider-tangle/src/tangle-prompt.ts index 8906687..1c6a5cd 100644 --- a/packages/agent-provider-tangle/src/tangle-prompt.ts +++ b/packages/agent-provider-tangle/src/tangle-prompt.ts @@ -114,21 +114,44 @@ type ValidatedSandboxPromptResult = Record & { executionId?: string; }; +const SANDBOX_OPTIONAL_RESULT_FIELDS = new Set([ + "executionId", + "response", + "error", + "errorCode", + "toolInvocations", + "approval", + "question", + "plan", + "traceId", + "usage", + "costUsd", +]); + export function validatedSandboxPromptResult( result: PromptResult, ): ValidatedSandboxPromptResult { if (!result || typeof result !== "object" || Array.isArray(result)) { throw new Error("Tangle prompt returned no result object"); } - const record = result as unknown as Record; + const source = result as unknown as Record; if ( - Object.hasOwn(record, "contextTransferReceipt") && - record.contextTransferReceipt === undefined + Object.hasOwn(source, "contextTransferReceipt") && + source.contextTransferReceipt === undefined ) { throw new Error( "Tangle prompt result returned a context receipt for a turn that requested no transfer", ); } + // The Sandbox SDK materializes absent optional response fields as + // `undefined`. They were absent on the JSON wire and must stay absent in the + // provider-neutral result before the strict JSON check runs. + const record = Object.fromEntries( + Object.entries(source).filter( + ([field, value]) => + value !== undefined || !SANDBOX_OPTIONAL_RESULT_FIELDS.has(field), + ), + ); assertBoundedJson(record); if (typeof record.success !== "boolean") { throw new Error("Tangle prompt result omitted its success status");