Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tangle-native-session-events.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions packages/agent-provider-tangle/src/leaf-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
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(
Expand Down
23 changes: 20 additions & 3 deletions packages/agent-provider-tangle/src/retained-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions packages/agent-provider-tangle/src/tangle-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
29 changes: 26 additions & 3 deletions packages/agent-provider-tangle/src/tangle-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,44 @@ type ValidatedSandboxPromptResult = Record<string, unknown> & {
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<string, unknown>;
const source = result as unknown as Record<string, unknown>;
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");
Expand Down
Loading