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..74e5d09c6ff 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; @@ -379,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 67cb927ed9d..afc7f97d041 100644 --- a/actions/setup/js/parse_opencode_log.test.cjs +++ b/actions/setup/js/parse_opencode_log.test.cjs @@ -14,6 +14,99 @@ 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.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( { @@ -126,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/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 e2e1ac4c3f1..dc9cc5ab591 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; structuredContent?: JsonValue; result?: JsonValue; error?: JsonValue; diff --git a/actions/setup/js/types/unified_session.d.ts b/actions/setup/js/types/unified_session.d.ts index 5655c6ce6e3..091010b8c23 100644 --- a/actions/setup/js/types/unified_session.d.ts +++ b/actions/setup/js/types/unified_session.d.ts @@ -857,6 +857,7 @@ export interface UnifiedSessionEventDataMap { | "toolName" | "success" | "output" + | "outputTruncated" | "structuredContent" | "result" | "error" diff --git a/actions/setup/js/unified_session_payload.cjs b/actions/setup/js/unified_session_payload.cjs index d37e67559e9..5bd72786d7b 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"], structuredContent: ["structuredContent"], result: ["result"], error: ["error"], diff --git a/docs/public/schemas/agent-session.schema.json b/docs/public/schemas/agent-session.schema.json index 67eb75beee8..c5a383bb921 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." }, "structuredContent": { "$ref": "#/definitions/JsonValue" }, "result": { "$ref": "#/definitions/JsonValue" }, "error": { "$ref": "#/definitions/JsonValue" }, diff --git a/docs/public/schemas/unified-session.schema.json b/docs/public/schemas/unified-session.schema.json index 02922eecda3..48777a50187 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." }, "structuredContent": { "$ref": "#/definitions/JsonValue" }, "result": { "$ref": "#/definitions/JsonValue" }, "error": { "$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 7012878e124..2298cda9923 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. | | `structuredContent` | Optional separately supplied JSON-compatible tool result, retained alongside `output` without flattening nested fields or replacing explicit empty or falsy values. | | `durationMs` | Source-dependent finite nonnegative duration in milliseconds, including zero. | | `error` | Optional native error string, object, or other supported error value. | @@ -1831,10 +1832,24 @@ 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. +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.