Skip to content
Open
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
2 changes: 1 addition & 1 deletion actions/setup/js/agent_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
*/
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);
Expand Down Expand Up @@ -209,7 +209,7 @@
if (typeof input === "string") {
try {
input = JSON.parse(input);
} catch {

Check warning on line 212 in actions/setup/js/agent_session.cjs

View workflow job for this annotation

GitHub Actions / lint-js

Empty catch block silently swallows the error. Log it (e.g. core.debug/core.warning), assign a fallback value, or add an explicit comment explaining why the error is intentionally ignored
// Preserve malformed provider arguments verbatim.
}
}
Expand All @@ -232,7 +232,7 @@
if (typeof input === "string") {
try {
input = JSON.parse(input);
} catch {

Check warning on line 235 in actions/setup/js/agent_session.cjs

View workflow job for this annotation

GitHub Actions / lint-js

Empty catch block silently swallows the error. Log it (e.g. core.debug/core.warning), assign a fallback value, or add an explicit comment explaining why the error is intentionally ignored
// Preserve malformed provider arguments verbatim.
}
}
Expand Down
10 changes: 10 additions & 0 deletions actions/setup/js/agent_session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions actions/setup/js/parse_opencode_log.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand All @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions actions/setup/js/parse_opencode_log.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
13 changes: 13 additions & 0 deletions actions/setup/js/scripts/session_schemas.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/types/agent_session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/types/unified_session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ export interface UnifiedSessionEventDataMap {
| "toolName"
| "success"
| "output"
| "outputTruncated"
| "structuredContent"
| "result"
| "error"
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/unified_session_payload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ const EVENT_FIELDS = {
...TOOL_FIELDS,
success: ["success"],
output: ["output"],
outputTruncated: ["outputTruncated"],
structuredContent: ["structuredContent"],
result: ["result"],
error: ["error"],
Expand Down
1 change: 1 addition & 0 deletions docs/public/schemas/agent-session.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions docs/public/schemas/unified-session.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -1831,10 +1832,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.

Expand Down
Loading