From 3c5d45c679fc30f21b9bbbc1fb2835402f5b0950 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sun, 8 Nov 2026 19:10:54 -0800 Subject: [PATCH 1/2] fix: preserve OpenCode session scope and output truncation Recognize native sessionID context and project typed outputTruncated flags without exposing provider metadata. Preserve accounting reconciliation source order and add live-shaped regressions and schemas. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/agent_session.cjs | 2 +- actions/setup/js/agent_session.test.cjs | 10 +++ actions/setup/js/parse_opencode_log.cjs | 3 + actions/setup/js/parse_opencode_log.test.cjs | 63 +++++++++++++++++++ .../setup/js/scripts/session_schemas.test.cjs | 13 ++++ actions/setup/js/types/agent_session.d.ts | 2 + actions/setup/js/types/unified_session.d.ts | 19 +++++- actions/setup/js/unified_session_payload.cjs | 1 + docs/public/schemas/agent-session.schema.json | 1 + .../schemas/unified-session.schema.json | 1 + .../unified-agent-session-specification.md | 11 +++- 11 files changed, 123 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/agent_session.cjs b/actions/setup/js/agent_session.cjs index 6ab4bdea868..a89b1b2b6c3 100644 --- a/actions/setup/js/agent_session.cjs +++ b/actions/setup/js/agent_session.cjs @@ -40,7 +40,7 @@ function isMetric(value) { */ function sessionContext(event) { const data = event?.data ?? {}; - const sessionId = data.sessionId !== undefined ? data.sessionId : event?.sessionId !== undefined ? event.sessionId : event?.session_id !== undefined ? event.session_id : data.session_id; + const sessionId = data.sessionId !== undefined ? data.sessionId : event?.sessionId !== undefined ? event.sessionId : event?.session_id !== undefined ? event.session_id : data.session_id !== undefined ? data.session_id : event?.sessionID; const parentToolUseId = data.parentToolUseId !== undefined ? data.parentToolUseId : event?.parentToolUseId !== undefined ? event.parentToolUseId : event?.parent_tool_use_id !== undefined ? event.parent_tool_use_id : data.parent_tool_use_id; const agentId = data.agentId ?? (typeof event?.type === "string" && event.type.startsWith("subagent.") ? undefined : event?.agentId); diff --git a/actions/setup/js/agent_session.test.cjs b/actions/setup/js/agent_session.test.cjs index 3ba2e0b375b..611e43f64c0 100644 --- a/actions/setup/js/agent_session.test.cjs +++ b/actions/setup/js/agent_session.test.cjs @@ -66,6 +66,16 @@ const canonical = freeze([ ]); describe("Unified Agent Session 1.0.0 conformance", () => { + it("recognizes native OpenCode sessionID without overriding existing context precedence", () => { + const native = { type: "tool.execution_complete", sessionID: "opencode-session", data: {} }; + expect(sessionContext(native)).toEqual({ sessionId: "opencode-session" }); + expect(sessionContext({ ...native, data: { sessionId: null } })).toEqual({ sessionId: null }); + expect(sessionContext({ ...native, sessionId: "canonical" })).toEqual({ sessionId: "canonical" }); + expect(sessionContext({ ...native, session_id: "legacy" })).toEqual({ sessionId: "legacy" }); + expect(sessionContext({ ...native, data: { session_id: "legacy-data" } })).toEqual({ sessionId: "legacy-data" }); + expect(sessionContext({ ...native, sessionID: 123 })).toEqual({}); + }); + it("retains observed assistant reasoning alongside provider refusals", () => { const source = freeze({ object: "chat.completion", diff --git a/actions/setup/js/parse_opencode_log.cjs b/actions/setup/js/parse_opencode_log.cjs index dd62fa1ad13..58116dda685 100644 --- a/actions/setup/js/parse_opencode_log.cjs +++ b/actions/setup/js/parse_opencode_log.cjs @@ -287,11 +287,13 @@ function parseOpenCodeLog(content) { const start = state.time?.start; const end = state.time?.end; const exitCode = isV2Part(part) ? state.metadata?.metadata?.exit : state.metadata?.exit; + const outputTruncated = isV2Part(part) ? state.metadata?.metadata?.truncated : state.metadata?.truncated; const completion = { toolCallId: callID, toolName: part.tool, status: state.status, ...(Object.hasOwn(state, "output") ? { output: state.output } : {}), + ...(typeof outputTruncated === "boolean" ? { outputTruncated } : {}), ...(Object.hasOwn(state, "result") ? { result: state.result } : {}), ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), ...(Number.isSafeInteger(exitCode) ? { exitCode } : {}), @@ -300,6 +302,7 @@ function parseOpenCodeLog(content) { }; const success = previous?.event.data.success === false ? false : (sessionToolSuccess({ ...raw.data, ...state, ...completion }) ?? state.status === "completed"); const previousData = { ...previous?.event.data }; + if (previous && typeof outputTruncated !== "boolean") delete previousData.outputTruncated; if (previous && state.error === undefined) delete previousData.error; if (previous && state.metadata === undefined) { delete previousData.metadata; diff --git a/actions/setup/js/parse_opencode_log.test.cjs b/actions/setup/js/parse_opencode_log.test.cjs index 67cb927ed9d..f2409ebb12d 100644 --- a/actions/setup/js/parse_opencode_log.test.cjs +++ b/actions/setup/js/parse_opencode_log.test.cjs @@ -14,6 +14,69 @@ const record = (type, part, extra = {}) => ({ type, timestamp, sessionID, part: const jsonl = records => records.map(value => JSON.stringify(value)).join("\n"); describe("OpenCode JSON session parser", () => { + it.each([true, false, undefined, "true"])("publishes observed v2 truncation and session context without provider metadata: %s", truncated => { + const tool = record("tool_use", { + partID: "prt_live_shape", + id: "call_live_shape", + tool: "grep", + state: { + status: "completed", + input: { pattern: '"quoted"\\path', include: "*.cjs" }, + output: "first match\nsecond match\n", + metadata: { + metadata: { truncated }, + content: [{ type: "text", text: "first match\nsecond match\n" }], + providerCall: { executed: false }, + providerResult: { executed: false }, + rawInput: '{"pattern":"private-provider-input"}', + }, + time: { start: timestamp, end: timestamp + 10 }, + }, + }); + const events = parseOpenCodeLog(jsonl([tool, record("text", { text: " done\n" }), record("step_finish", { reason: "stop", tokens: { input: 10, output: 2 } })])).logEntries; + const completion = events.find(event => event.type === "tool.execution_complete"); + if (typeof truncated === "boolean") expect(completion.data.outputTruncated).toBe(truncated); + else expect(completion.data).not.toHaveProperty("outputTruncated"); + expect(completion.data.metadata).toEqual(tool.part.state.metadata); + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", events }]); + expect(unified.every(event => event.data.sessionId === sessionID)).toBe(true); + const published = unified.find(event => event.type === "tool.execution_complete"); + expect(published.data).toMatchObject({ toolCallId: "call_live_shape", output: tool.part.state.output, success: true }); + if (typeof truncated === "boolean") expect(published.data.outputTruncated).toBe(truncated); + else expect(published.data).not.toHaveProperty("outputTruncated"); + expect(published.data).not.toHaveProperty("metadata"); + expect(JSON.stringify(unified)).not.toContain("private-provider-input"); + expect(JSON.parse(JSON.stringify(unified))).toEqual(unified); + }); + + it("retains v1 truncation and clears stale flags on reconciled tool snapshots", () => { + const first = record("tool_use", { callID: "call_v1", tool: "grep", state: { status: "completed", input: {}, output: "preview\n", metadata: { truncated: true } } }); + const events = parseOpenCodeLog(jsonl([first])).logEntries; + expect(events.find(event => event.type === "tool.execution_complete").data.outputTruncated).toBe(true); + for (const metadata of [{ truncated: false }, {}, { truncated: "false" }]) { + const replay = { ...first, timestamp: timestamp + 1, part: { ...first.part, state: { ...first.part.state, metadata, output: "latest\n" } } }; + const completions = parseOpenCodeLog(jsonl([first, replay])).logEntries.filter(event => event.type === "tool.execution_complete"); + expect(completions).toHaveLength(1); + expect(completions[0].data.output).toBe("latest\n"); + if (metadata.truncated === false) expect(completions[0].data.outputTruncated).toBe(false); + else expect(completions[0].data).not.toHaveProperty("outputTruncated"); + } + }); + + it("keeps live-shaped accounting reconciliation in source order without double-counting turns", () => { + const first = record("step_finish", { messageID: "msg_first", tokens: { input: 10, output: 2 }, reason: "tool-calls" }); + const text = record("text", { messageID: "msg_second", text: "done\n" }, { timestamp: timestamp + 100 }); + const reconciled = { ...first, part: { ...first.part, tokens: { input: 20, output: 3 } } }; + const final = record("step_finish", { id: "gh_aw_export_msg_second", messageID: "msg_second", tokens: { input: 30, output: 4 }, reason: "stop" }, { timestamp: timestamp + 100 }); + const events = parseOpenCodeLog(jsonl([first, text, reconciled, final])).logEntries; + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", timestampUnit: "milliseconds", events }]); + expect(unified.map(event => event.type)).toEqual(events.map(event => event.type)); + expect(unified.map(event => event.timestamp)).toEqual(events.map(event => event.timestamp)); + expect(unified.map(event => event.provenance.index)).toEqual(events.map((_, index) => index)); + expect(unified.filter(event => event.type === "opencode.step_finish")).toHaveLength(3); + expect(unified.at(-1).data).toMatchObject({ numTurns: 2, usage: { inputTokens: 50, outputTokens: 7 }, sessionId: sessionID }); + }); + it("recovers only completed assistant-step metadata from v2 exports", () => { const steps = getOpenCodeSessionSteps( { diff --git a/actions/setup/js/scripts/session_schemas.test.cjs b/actions/setup/js/scripts/session_schemas.test.cjs index 716af04c828..f7afda6ce89 100644 --- a/actions/setup/js/scripts/session_schemas.test.cjs +++ b/actions/setup/js/scripts/session_schemas.test.cjs @@ -99,6 +99,19 @@ describe("Generated session schemas", () => { expect(validateSession(jsonl([header, unified({ type: "vendor.progress", data: { arbitrary: { body: false } } })]))).toBe(2); }); + it("requires boolean output truncation in canonical and unified completions", () => { + for (const outputTruncated of [true, false]) { + const event = { type: "tool.execution_complete", data: { output: "preview\n", outputTruncated } }; + expect(validateSession(jsonl([event]), "agent")).toBe(1); + expect(validateSession(jsonl([header, unified(normalizeUnifiedSessionEvent(event))]))).toBe(2); + } + for (const outputTruncated of ["false", 0, null]) { + const event = { type: "tool.execution_complete", data: { outputTruncated } }; + expect(() => validateSession(jsonl([event]), "agent")).toThrow("Invalid session event"); + expect(() => validateSession(jsonl([header, unified(event)]))).toThrow("Invalid session event"); + } + }); + it("validates dependency job results with required IDs and native Actions outcomes", () => { for (const result of ["success", "failure", "cancelled", "skipped"]) { expect(validateSession(jsonl([header, unified({ type: "workflow.job_result", data: { jobId: "custom-job", result, durationMs: 0 } })]))).toBe(2); diff --git a/actions/setup/js/types/agent_session.d.ts b/actions/setup/js/types/agent_session.d.ts index 62dda769216..9e1816017c7 100644 --- a/actions/setup/js/types/agent_session.d.ts +++ b/actions/setup/js/types/agent_session.d.ts @@ -84,6 +84,8 @@ export interface ToolExecutionCompleteData { workflowRunId?: string; success?: boolean; output?: JsonValue; + /** Native output truncation, not a collector completeness guarantee. */ + outputTruncated?: boolean; result?: JsonValue; error?: JsonValue; fileEdits?: JsonValue; diff --git a/actions/setup/js/types/unified_session.d.ts b/actions/setup/js/types/unified_session.d.ts index f3937585c89..dd9af64c2af 100644 --- a/actions/setup/js/types/unified_session.d.ts +++ b/actions/setup/js/types/unified_session.d.ts @@ -852,7 +852,24 @@ export interface UnifiedSessionEventDataMap { } & UnifiedSessionContextData; "tool.execution_complete": Pick< ToolExecutionCompleteData, - "toolCallId" | "stepIndex" | "toolName" | "success" | "output" | "result" | "error" | "fileEdits" | "durationMs" | "exitCode" | "status" | "mcpServerName" | "isError" | "taskId" | "taskType" | "workflowName" | "workflowRunId" + | "toolCallId" + | "stepIndex" + | "toolName" + | "success" + | "output" + | "outputTruncated" + | "result" + | "error" + | "fileEdits" + | "durationMs" + | "exitCode" + | "status" + | "mcpServerName" + | "isError" + | "taskId" + | "taskType" + | "workflowName" + | "workflowRunId" > & UnifiedSessionContextData; "dynamicWorkflows.task_started": DynamicWorkflowObservationData; diff --git a/actions/setup/js/unified_session_payload.cjs b/actions/setup/js/unified_session_payload.cjs index d4df2a00b75..44b00944f06 100644 --- a/actions/setup/js/unified_session_payload.cjs +++ b/actions/setup/js/unified_session_payload.cjs @@ -255,6 +255,7 @@ const EVENT_FIELDS = { ...TOOL_FIELDS, success: ["success"], output: ["output"], + outputTruncated: ["outputTruncated"], result: ["result"], error: ["error"], fileEdits: ["fileEdits"], diff --git a/docs/public/schemas/agent-session.schema.json b/docs/public/schemas/agent-session.schema.json index 89f7a9769bb..0fca28a8f28 100644 --- a/docs/public/schemas/agent-session.schema.json +++ b/docs/public/schemas/agent-session.schema.json @@ -140,6 +140,7 @@ "workflowRunId": { "type": "string" }, "success": { "type": "boolean" }, "output": { "$ref": "#/definitions/JsonValue" }, + "outputTruncated": { "type": "boolean", "description": "Native output truncation, not a collector completeness guarantee." }, "result": { "$ref": "#/definitions/JsonValue" }, "error": { "$ref": "#/definitions/JsonValue" }, "fileEdits": { "$ref": "#/definitions/JsonValue" }, diff --git a/docs/public/schemas/unified-session.schema.json b/docs/public/schemas/unified-session.schema.json index f33de7b8a19..57baa93fa8f 100644 --- a/docs/public/schemas/unified-session.schema.json +++ b/docs/public/schemas/unified-session.schema.json @@ -327,6 +327,7 @@ "toolName": { "type": "string" }, "success": { "type": "boolean" }, "output": { "$ref": "#/definitions/JsonValue" }, + "outputTruncated": { "type": "boolean", "description": "Native output truncation, not a collector completeness guarantee." }, "result": { "$ref": "#/definitions/JsonValue" }, "error": { "$ref": "#/definitions/JsonValue" }, "fileEdits": { "$ref": "#/definitions/JsonValue" }, diff --git a/docs/src/content/docs/specs/unified-agent-session-specification.md b/docs/src/content/docs/specs/unified-agent-session-specification.md index ace27be517a..51c498ac370 100644 --- a/docs/src/content/docs/specs/unified-agent-session-specification.md +++ b/docs/src/content/docs/specs/unified-agent-session-specification.md @@ -765,6 +765,7 @@ Native `parameters` remains a reader alias for `input`. If both are present, rea | `toolName` | Source-dependent native name, or a name recovered from an exact matching start. | | `success` | Boolean when the source completion establishes an outcome; omitted when unknown. | | `output` | Source-dependent JSON-compatible output, not restricted to text. | +| `outputTruncated` | Optional boolean indicating native output truncation. Absence means unknown; `false` does not certify collector completeness. | | `durationMs` | Source-dependent finite nonnegative duration in milliseconds, including zero. | | `error` | Optional native error string, object, or other supported error value. | | `result` | Optional native result value, including Copilot `result.content` arrays or JSON content blocks. | @@ -1825,10 +1826,18 @@ assistant messages. The custom adapter also recognizes this signature. | Native logfmt `message="server unavailable"` with a failed MCP server, or harness `opencode.mcp_failure` | Report the observed server name in `mcpFailures`; preserve harness events without interpreting arbitrary prose as failure evidence. | OpenCode envelope timestamps use milliseconds and remain unchanged, including -when a completed tool part expands into invocation and result events. Duplicate +when a completed tool part expands into invocation and result events. The native +`sessionID` scopes core events through canonical `data.sessionId`. Tool metadata +projects only the observed boolean `truncated` into `outputTruncated` (nested +under `metadata.metadata` for v2); provider envelopes remain in the native and +parser artifacts, not the unified projection. Duplicate parts are identified within their session, not globally. Cache-read and cache-write tokens are separate from the reported input count; reasoning tokens are an output subset, not an additional contribution to total tokens. +Exported accounting revisions remain after the original observations in source +order, even when their timestamps regress. Usage is reconciled by session and +message identity; repeated step observations and shared source IDs are not +additional turns or globally unique event IDs. Synthetic coverage lives in `parse_opencode_log.test.cjs` and `opencode_workflow.test.cjs`; it is not evidence of a live smoke run. From 9dcb3589a7793dd46f80c4dfa85f7919bf53778c Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sun, 8 Nov 2026 19:52:50 -0800 Subject: [PATCH 2/2] fix: leave mixed OpenCode session aggregates unscoped Explicitly suppress native session aliases on cross-session aggregate results while preserving individually scoped observations and source metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/parse_opencode_log.cjs | 2 ++ actions/setup/js/parse_opencode_log.test.cjs | 34 ++++++++++++++++++- .../unified-agent-session-specification.md | 6 ++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/parse_opencode_log.cjs b/actions/setup/js/parse_opencode_log.cjs index 58116dda685..74e5d09c6ff 100644 --- a/actions/setup/js/parse_opencode_log.cjs +++ b/actions/setup/js/parse_opencode_log.cjs @@ -382,6 +382,8 @@ function parseOpenCodeLog(content) { if (Object.keys(usage).length) usage.input_tokens_include_cache = false; logEntries.push( createSessionEvent(sourceMetadata(resultSource), "session.result", { + // Null suppresses native session aliases without discarding the source. + ...(sessions.size > 1 ? { sessionId: null } : {}), ...(stepReports.size > 0 ? { numTurns: stepReports.size } : {}), ...(Object.keys(usage).length ? { usage } : {}), ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), diff --git a/actions/setup/js/parse_opencode_log.test.cjs b/actions/setup/js/parse_opencode_log.test.cjs index f2409ebb12d..afc7f97d041 100644 --- a/actions/setup/js/parse_opencode_log.test.cjs +++ b/actions/setup/js/parse_opencode_log.test.cjs @@ -77,6 +77,36 @@ describe("OpenCode JSON session parser", () => { expect(unified.at(-1).data).toMatchObject({ numTurns: 2, usage: { inputTokens: 50, outputTokens: 7 }, sessionId: sessionID }); }); + it.each([sessionID, "ses_other"].flatMap(secondSessionID => [undefined, { sessionId: secondSessionID, nativeDetail: "retained" }].map(data => ({ secondSessionID, data }))))( + "projects aggregate accounting with explicit scope for %j", + ({ secondSessionID, data }) => { + const first = record("step_finish", { tokens: { input: 5, output: 2 }, cost: 0.1 }); + const reconciled = { ...first, part: { ...first.part, tokens: { input: 10, output: 3 } } }; + const text = record("text", { sessionID: secondSessionID, messageID: "msg_second", text: "done\n" }, { sessionID: secondSessionID, timestamp: timestamp + 100 }); + const final = record("step_finish", { sessionID: secondSessionID, messageID: "msg_second", tokens: { input: 20, output: 4 }, cost: 0.2 }, { sessionID: secondSessionID, timestamp: timestamp + 100, ...(data ? { data } : {}) }); + const records = [first, text, reconciled, final]; + const original = structuredClone(records); + const events = parseOpenCodeLog(jsonl(records)).logEntries; + const resultSessionId = secondSessionID === sessionID ? sessionID : null; + expect(events.at(-1)).toMatchObject({ + sessionID: secondSessionID, + part: final.part, + data: { ...data, ...(resultSessionId === null ? { sessionId: null } : {}), numTurns: 2, usage: { input_tokens: 30, output_tokens: 7 } }, + }); + expect(events.at(-1).data.totalCostUsd).toBeCloseTo(0.3); + expect(records).toEqual(original); + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", timestampUnit: "milliseconds", events }]); + expect(unified.map(event => event.type)).toEqual(events.map(event => event.type)); + expect(unified.map(event => event.timestamp)).toEqual(events.map(event => event.timestamp)); + expect(unified.map(event => event.provenance.index)).toEqual(events.map((_, index) => index)); + expect(unified.find(event => event.type === "assistant.message").data).toMatchObject({ sessionId: secondSessionID, content: "done\n" }); + expect(unified.filter(event => event.type === "opencode.step_finish").map(event => event.data.sessionId)).toEqual([sessionID, sessionID, secondSessionID]); + expect(unified.at(-1).data).toMatchObject({ sessionId: resultSessionId, numTurns: 2, usage: { inputTokens: 30, outputTokens: 7 } }); + expect(unified.at(-1).data.totalCostUsd).toBeCloseTo(0.3); + if (data) expect(events.at(-1).data.nativeDetail).toBe("retained"); + } + ); + it("recovers only completed assistant-step metadata from v2 exports", () => { const steps = getOpenCodeSessionSteps( { @@ -189,7 +219,9 @@ describe("OpenCode JSON session parser", () => { expect(events.at(-1).data).toEqual({ totalCostUsd: 0.2, usage: { input_tokens: 110, output_tokens: 5, reasoning_output_tokens: 3, input_tokens_include_cache: false } }); expect(parseOpenCodeLog(jsonl(events)).logEntries).toEqual(events); const otherSession = parseOpenCodeLog(jsonl([compaction, { ...compaction, sessionID: "other_session" }])).logEntries; - expect(otherSession.at(-1).data).toEqual({ totalCostUsd: 0.2, usage: { input_tokens: 200, output_tokens: 10, reasoning_output_tokens: 6, input_tokens_include_cache: false } }); + expect(otherSession.at(-1).data).toEqual({ sessionId: null, totalCostUsd: 0.2, usage: { input_tokens: 200, output_tokens: 10, reasoning_output_tokens: 6, input_tokens_include_cache: false } }); + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", events: otherSession }]); + expect(unified.at(-1).data).toMatchObject({ sessionId: null, usage: { inputTokens: 200, outputTokens: 10, reasoningOutputTokens: 6 } }); }); it.each(["id", "partID"])("reconciles assistant usage by message across fresh %s projections and export recovery", idField => { diff --git a/docs/src/content/docs/specs/unified-agent-session-specification.md b/docs/src/content/docs/specs/unified-agent-session-specification.md index 7c4744bd10c..2298cda9923 100644 --- a/docs/src/content/docs/specs/unified-agent-session-specification.md +++ b/docs/src/content/docs/specs/unified-agent-session-specification.md @@ -1844,6 +1844,12 @@ Exported accounting revisions remain after the original observations in source order, even when their timestamps regress. Usage is reconciled by session and message identity; repeated step observations and shared source IDs are not additional turns or globally unique event IDs. +When recognized native records span multiple sessions, the appended aggregate +`session.result` sets `data.sessionId: null`, overriding session aliases from its +last source record rather than attributing combined usage, cost, turns, or errors +to that session. The source envelope and payload additions remain in the parser +artifact; individual observations retain their session scope, and single-session +results remain scoped as before. Synthetic coverage lives in `parse_opencode_log.test.cjs` and `opencode_workflow.test.cjs`; it is not evidence of a live smoke run.