From 607e0993a7d90f8d62d45597473297b1a24b7b9e Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sun, 8 Nov 2026 19:17:04 -0800 Subject: [PATCH 1/2] Preserve Pi message clocks and unified tool correlation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/pi_session.cjs | 37 +++++++--- actions/setup/js/pi_session.test.cjs | 73 +++++++++++++++++++ actions/setup/js/pi_session_pipeline.test.cjs | 31 +++++++- .../setup/js/types/agent_session.type-test.ts | 12 +++ actions/setup/js/types/unified_session.d.ts | 34 +++++---- actions/setup/js/unified_session_order.cjs | 2 +- .../setup/js/unified_session_order.test.cjs | 25 +++++++ actions/setup/js/unified_session_payload.cjs | 6 +- .../setup/js/unified_session_payload.test.cjs | 19 +++++ .../schemas/unified-session.schema.json | 6 ++ docs/src/content/docs/reference/engines/pi.md | 18 +++++ .../unified-agent-session-specification.md | 15 +++- 12 files changed, 244 insertions(+), 34 deletions(-) diff --git a/actions/setup/js/pi_session.cjs b/actions/setup/js/pi_session.cjs index e237aaa0b6d..8551ca351df 100644 --- a/actions/setup/js/pi_session.cjs +++ b/actions/setup/js/pi_session.cjs @@ -106,6 +106,11 @@ function messageIdentity(message) { return undefined; } +/** @param {SessionEvent} event @param {any} message */ +function retainMessageTimestamp(event, message) { + if (event.timestamp === undefined && (typeof message?.timestamp === "string" || typeof message?.timestamp === "number")) event.timestamp = message.timestamp; +} + /** @returns {MessageState} */ function newMessageState() { return { text: new Map(), thinking: new Map(), calls: new Map(), blocks: new Map(), errors: [], finalized: false }; @@ -298,13 +303,16 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { const id = message.toolCallId; if (id !== undefined && (executionEnds.has(id) || completions.has(id))) return; if (id !== undefined) completions.add(id); - emit(raw, "tool.execution_complete", { - ...message, - toolCallId: id, - toolName: message.toolName, - output: message.content, - success: message.isError === true || isPiResultError(message) ? false : message.isError === false ? true : undefined, - }); + retainMessageTimestamp( + emit(raw, "tool.execution_complete", { + ...message, + toolCallId: id, + toolName: message.toolName, + output: message.content, + success: message.isError === true || isPiResultError(message) ? false : message.isError === false ? true : undefined, + }), + message + ); }; const emitMessage = (raw, message, fallback) => { @@ -312,7 +320,7 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { if (!["assistant", "user", "toolResult"].includes(message.role)) { if (!snapshots.some(snapshot => isDeepStrictEqual(snapshot.message, message))) { snapshots.push({ message: structuredClone(message), state: fallback }); - emit(raw, "session.info", { sourceType: "message_snapshot", role: message.role, content: message }); + retainMessageTimestamp(emit(raw, "session.info", { sourceType: "message_snapshot", role: message.role, content: message }), message); } return; } @@ -363,15 +371,20 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { ...((message.id ?? message.responseId) !== undefined ? { messageId: message.id ?? message.responseId } : {}), }; for (const segments of [...state.text.values(), ...state.thinking.values()]) { - for (const event of segments.segments) Object.assign(event.data, structuredClone(metadata)); + for (const event of segments.segments) { + Object.assign(event.data, structuredClone(metadata)); + retainMessageTimestamp(event, message); + } + } + for (const event of [...state.calls.values(), ...state.blocks.values()]) { + Object.assign(event.data, structuredClone(metadata)); + retainMessageTimestamp(event, message); } - for (const event of state.calls.values()) Object.assign(event.data, structuredClone(metadata)); - for (const event of state.blocks.values()) Object.assign(event.data, structuredClone(metadata)); if (!snapshots.some(snapshot => snapshot.state === state && isDeepStrictEqual(snapshot.message, message))) snapshots.push({ message: structuredClone(message), state }); if (raw.type === "message_end" || raw.type === "turn_end") state.finalized = true; if (message.errorMessage !== undefined && !state.errors.some(error => isDeepStrictEqual(error, message.errorMessage))) { state.errors.push(structuredClone(message.errorMessage)); - emit(raw, "session.error", { error: message.errorMessage, model: message.responseModel ?? message.model }); + retainMessageTimestamp(emit(raw, "session.error", { error: message.errorMessage, model: message.responseModel ?? message.model }), message); } }; diff --git a/actions/setup/js/pi_session.test.cjs b/actions/setup/js/pi_session.test.cjs index 26dd8e312ca..1d177445bb4 100644 --- a/actions/setup/js/pi_session.test.cjs +++ b/actions/setup/js/pi_session.test.cjs @@ -11,6 +11,79 @@ const parse = records => parsePiLog(records.map(record => JSON.stringify(record) const byType = (events, type) => events.filter(event => event.type === type); describe("Pi CI stream regressions", () => { + // Synthetic content in the shape of Smoke Pi 42539928028 and + // Smoke Pi Sub Agents 39516073462; no captured payloads are published. + it.each([0, 1794184043729, "2026-11-09T00:27:23.729Z"])("retains the observed message clock %s through streaming reconciliation and publication", timestamp => { + const message = { + role: "assistant", + responseId: "sanitized-response", + model: "requested-model", + responseModel: "served-model", + timestamp, + content: [ + { type: "thinking", thinking: "Sanitized reasoning." }, + { type: "toolCall", id: "sanitized-tool", name: "lookup", arguments: { nested: [false, 0, null, "line\n\u00e9"] } }, + { type: "text", text: "Sanitized answer.\n" }, + ], + usage: { input: 10, output: 3 }, + }; + const records = [ + { type: "turn_start" }, + { type: "message_update", assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "Sanitized reasoning." } }, + { type: "message_update", assistantMessageEvent: { type: "toolcall_start", contentIndex: 1, id: "sanitized-tool", toolName: "lookup" } }, + { type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 2, delta: "Sanitized answer.\n" } }, + { type: "message_end", message }, + { type: "turn_end", message }, + { type: "agent_end", messages: [message] }, + ]; + const original = structuredClone(records); + const events = parse(records).logEntries; + const messages = events.filter(event => event.data.messageId === message.responseId); + expect(messages.map(event => event.type)).toEqual(["assistant.reasoning", "tool.execution_start", "assistant.message"]); + expect(messages.every(event => event.timestamp === timestamp)).toBe(true); + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", events }]); + for (const event of unified.filter(event => event.type !== "session.result")) { + expect(event.timestamp).toBe(timestamp); + expect(event.provenance.timestampMs).toBe(typeof timestamp === "number" ? timestamp : Date.parse(timestamp)); + expect(event.data).toMatchObject({ messageId: message.responseId, model: "served-model" }); + expect(event.data).not.toHaveProperty("messageMetadata"); + expect(event).not.toHaveProperty("assistantMessageEvent"); + } + expect(unified.find(event => event.type === "tool.execution_start").data.input).toEqual(message.content[1].arguments); + expect(selectSessionResult(events).usage).toMatchObject({ input_tokens: 10, output_tokens: 3 }); + expect(records).toEqual(original); + }); + + it("preserves source clocks over embedded message clocks and never invents a missing clock", () => { + const message = { role: "assistant", timestamp: 10, content: [{ type: "text", text: "Sanitized answer" }] }; + const events = transformPiV3Entries([ + { type: "message_update", timestamp: 5, assistantMessageEvent: { type: "text_delta", delta: "Sanitized answer" } }, + { type: "message_end", timestamp: 20, message }, + ]); + expect(events).toHaveLength(1); + expect(events[0].timestamp).toBe(5); + expect(events[0].data.messageMetadata.timestamp).toBe(10); + const [untimed] = transformPiV3Entries([{ type: "message_end", message: { ...message, timestamp: undefined } }]); + expect(untimed).not.toHaveProperty("timestamp"); + }); + + it("retains child, tool-result and provider-error clocks without duplicating snapshots or parent usage", () => { + const child = { role: "assistant", responseId: "child-response", model: "fixture-model", timestamp: 3, content: [{ type: "text", text: "Child answer" }], usage: { input: 5, output: 2 } }; + const records = [ + { type: "gh_aw_subagent_dispatch", agent: "reader", invocation_id: "child", timestamp: 1 }, + { type: "gh_aw_subagent_event", agent: "reader", invocation_id: "child", timestamp: 4, event: { type: "message_end", message: child } }, + { type: "gh_aw_subagent_event", agent: "reader", invocation_id: "child", timestamp: 5, event: { type: "turn_end", message: child } }, + { type: "message_end", message: { role: "toolResult", toolCallId: "tool", timestamp: 6, content: [{ type: "text", text: "result" }], isError: false } }, + { type: "message_end", message: { role: "assistant", timestamp: 7, content: [], errorMessage: "Sanitized provider failure", usage: { input: 0, output: 0 } } }, + ]; + const events = parse(records).logEntries; + expect(byType(events, "assistant.message")).toMatchObject([{ agentId: "child", timestamp: 3 }]); + expect(byType(events, "tool.execution_complete")).toMatchObject([{ timestamp: 6 }]); + expect(byType(events, "session.error")).toMatchObject([{ timestamp: 7 }]); + expect(byType(events, "subagent.request")).toHaveLength(1); + expect(selectSessionResult(events).usage).toMatchObject({ input_tokens: 0, output_tokens: 0 }); + }); + it("retains sampled child text and reasoning in their evidenced dispatch scope through unified projection", () => { const original = structuredClone(childObservations); const records = [...success, ...childObservations]; diff --git a/actions/setup/js/pi_session_pipeline.test.cjs b/actions/setup/js/pi_session_pipeline.test.cjs index 5e3f1cfc72b..622cc551108 100644 --- a/actions/setup/js/pi_session_pipeline.test.cjs +++ b/actions/setup/js/pi_session_pipeline.test.cjs @@ -7,7 +7,7 @@ import { parsePiLog } from "./parse_pi_log.cjs"; import { runLogParser } from "./log_parser_bootstrap.cjs"; import { writeUnifiedSession } from "./unified_session.cjs"; import { normalizeUnifiedSessionEvent } from "./unified_session_payload.cjs"; -import { createSessionValidator } from "./scripts/validate_session.cjs"; +import { createSessionValidator, validateSession } from "./scripts/validate_session.cjs"; describe("Pi native-to-unified artifact pipeline", () => { let root; @@ -40,6 +40,35 @@ describe("Pi native-to-unified artifact pipeline", () => { fs.rmSync(root, { recursive: true, force: true }); }); + it.each([false, true])("publishes message clocks and tool correlation from saved canonical events (metadata-only clocks=%s)", legacy => { + const records = [...success, ...childObservations]; + const events = JSON.parse(JSON.stringify(parsePiLog(records.map(JSON.stringify).join("\n")).logEntries)); + if (legacy) { + for (const event of events) if (event.data.messageMetadata?.timestamp !== undefined) delete event.timestamp; + } + const original = structuredClone(events); + fs.writeFileSync(path.join(root, "agent-session.jsonl"), events.map(JSON.stringify).join("\n") + "\n"); + const unified = writeUnifiedSession({ rootDir: root, engine: "pi", warn: vi.fn() }); + const agent = unified.filter(event => event.provenance.path === "agent-session.jsonl"); + const messages = agent.filter(event => event.type === "assistant.message" || event.type === "assistant.reasoning" || event.type === "tool.execution_start"); + expect(messages).toHaveLength(4); + for (const event of messages) { + const native = events[event.provenance.index]; + expect(event.timestamp).toBe(native.data.messageMetadata.timestamp); + expect(event.provenance.timestampMs).toBe(native.data.messageMetadata.timestamp); + expect(event.data.messageId).toBe(native.data.messageId); + expect(event.data.model).toBe(native.data.model); + expect(event.data).not.toHaveProperty("messageMetadata"); + } + expect(messages.find(event => event.type === "tool.execution_start").data.input).toEqual({ command: "printf sanitized" }); + expect(messages.find(event => event.agentId)?.agentId).toBe("legacy:pi:sanitized-reader:1"); + const bytes = fs.readFileSync(path.join(root, "usage/aw_session.jsonl"), "utf8"); + expect(validateSession(bytes)).toBe(unified.length); + expect(readEvents(path.join(root, "agent-session.jsonl"))).toEqual(original); + writeUnifiedSession({ rootDir: root, engine: "pi", warn: vi.fn() }); + expect(fs.readFileSync(path.join(root, "usage/aw_session.jsonl"), "utf8")).toBe(bytes); + }); + // Sanitized excerpts from separate runs, combined locally to exercise the // collector. This is not a new live execution or an exact single-run fixture. it.each([ diff --git a/actions/setup/js/types/agent_session.type-test.ts b/actions/setup/js/types/agent_session.type-test.ts index 91c9882d1bf..bafa65ebb23 100644 --- a/actions/setup/js/types/agent_session.type-test.ts +++ b/actions/setup/js/types/agent_session.type-test.ts @@ -117,3 +117,15 @@ const invalidUnifiedInit: UnifiedSessionEventDataMap["session.init"] = { tools: [], }; void invalidUnifiedInit; + +const correlatedUnifiedTools: UnifiedSessionEventDataMap["tool.execution_start" | "tool.execution_update" | "tool.execution_complete"][] = [ + { toolCallId: "call", messageId: "response", model: "served-model", input: false }, + { toolCallId: "call", messageId: "response", model: "served-model", output: null }, + { toolCallId: "call", messageId: "response", model: "served-model", success: false }, +]; +void correlatedUnifiedTools; +const invalidUnifiedToolEnvelope: UnifiedSessionEventDataMap["tool.execution_start"] = { + // @ts-expect-error The native envelope remains archival, not part of the unified payload. + messageMetadata: { timestamp: 0 }, +}; +void invalidUnifiedToolEnvelope; diff --git a/actions/setup/js/types/unified_session.d.ts b/actions/setup/js/types/unified_session.d.ts index f3937585c89..36a8ec2f7e5 100644 --- a/actions/setup/js/types/unified_session.d.ts +++ b/actions/setup/js/types/unified_session.d.ts @@ -834,26 +834,28 @@ export interface UnifiedSessionEventDataMap { /** Observed native usage, not an additive accounting contribution. */ usageSnapshot?: JsonValue; }; - "tool.execution_start": Pick & UnifiedSessionContextData; - "tool.execution_update": Pick & { - output?: JsonValue; - partial?: JsonValue; - delta?: JsonValue; - contentIndex?: JsonValue; - sourceType?: JsonValue; - /** Repeated observed metadata, never additive inference usage. */ - usageSnapshot?: JsonValue; - durationMs?: JsonValue; - status?: JsonValue; - command?: JsonValue; - taskId?: string; - taskType?: string; - error?: JsonValue; - } & UnifiedSessionContextData; + "tool.execution_start": Pick & Pick & UnifiedSessionContextData; + "tool.execution_update": Pick & + Pick & { + output?: JsonValue; + partial?: JsonValue; + delta?: JsonValue; + contentIndex?: JsonValue; + sourceType?: JsonValue; + /** Repeated observed metadata, never additive inference usage. */ + usageSnapshot?: JsonValue; + durationMs?: JsonValue; + status?: JsonValue; + command?: JsonValue; + taskId?: string; + taskType?: string; + error?: JsonValue; + } & UnifiedSessionContextData; "tool.execution_complete": Pick< ToolExecutionCompleteData, "toolCallId" | "stepIndex" | "toolName" | "success" | "output" | "result" | "error" | "fileEdits" | "durationMs" | "exitCode" | "status" | "mcpServerName" | "isError" | "taskId" | "taskType" | "workflowName" | "workflowRunId" > & + Pick & UnifiedSessionContextData; "dynamicWorkflows.task_started": DynamicWorkflowObservationData; "dynamicWorkflows.task_progress": DynamicWorkflowObservationData; diff --git a/actions/setup/js/unified_session_order.cjs b/actions/setup/js/unified_session_order.cjs index cf7defdce90..39a271c37bb 100644 --- a/actions/setup/js/unified_session_order.cjs +++ b/actions/setup/js/unified_session_order.cjs @@ -10,7 +10,7 @@ * @returns {bigint | undefined} */ function sessionTimestampNs(record, unit = "milliseconds") { - const value = record.timestamp ?? record.ts ?? record.time ?? record.created_at ?? record.message?.timestamp; + const value = record.timestamp ?? record.ts ?? record.time ?? record.created_at ?? record.message?.timestamp ?? record.data?.messageMetadata?.timestamp; if (unit === "nanoseconds") { if (typeof value !== "string" || !/^\d{1,22}$/.test(value)) return undefined; const ns = BigInt(value); diff --git a/actions/setup/js/unified_session_order.test.cjs b/actions/setup/js/unified_session_order.test.cjs index 5d85a97b12d..171abaffb5f 100644 --- a/actions/setup/js/unified_session_order.test.cjs +++ b/actions/setup/js/unified_session_order.test.cjs @@ -9,6 +9,31 @@ const header = { type: "session.format", data: { version: 1 }, provenance: { com const jsonl = events => [header, ...events].map(JSON.stringify).join("\n") + "\n"; describe("Unified trace ordering", () => { + it.each([0, 2000, "2026-11-09T00:27:23.729Z"])("orders saved Pi message metadata clocks without publishing the native envelope (%s)", timestamp => { + const clock = typeof timestamp === "number" ? timestamp : Date.parse(timestamp); + const saved = { + type: "tool.execution_start", + data: { toolCallId: "call", messageId: "message", model: "served-model", input: { nested: [false, 0] }, messageMetadata: { timestamp, provider: "fixture", usage: { input: 10 } } }, + }; + const original = structuredClone(saved); + expect(sessionTimestamp(saved)).toBe(clock); + const merged = mergeSessionSources([source("agent", [saved]), source("mcp", [event("before", clock - 1), event("after", clock + 1)])]); + expect(merged.map(record => record.id ?? record.data.toolCallId)).toEqual(["before", "call", "after"]); + expect(merged[1]).toMatchObject({ timestamp, data: { messageId: "message", model: "served-model" }, provenance: { timestampMs: clock, index: 0 } }); + expect(merged[1].data).not.toHaveProperty("messageMetadata"); + expect(validateSession(jsonl(merged))).toBe(4); + expect(saved).toEqual(original); + expect(mergeSessionSources([source("agent", [merged[1]])])[0].timestamp).toBe(timestamp); + }); + + it("does not overwrite an explicit source clock or replace invalid clocks with message metadata", () => { + const saved = { type: "assistant.message", timestamp: 10, data: { content: "exact", messageMetadata: { timestamp: 20 } } }; + expect(sessionTimestamp(saved)).toBe(10); + expect(mergeSessionSources([source("agent", [saved])])[0].timestamp).toBe(10); + expect(sessionTimestamp({ ...saved, timestamp: "invalid" })).toBeUndefined(); + expect(sessionTimestamp({ type: "assistant.message", data: { content: "untimed" } })).toBeUndefined(); + }); + it.each([[], [source("agent", [])], [source("agent", []), source("otel", [], "nanoseconds")]].map(sources => [sources]))("accepts empty source collections without inventing observations: %j", sources => { expect(mergeSessionSources(sources)).toEqual([]); }); diff --git a/actions/setup/js/unified_session_payload.cjs b/actions/setup/js/unified_session_payload.cjs index d4df2a00b75..a05681988a6 100644 --- a/actions/setup/js/unified_session_payload.cjs +++ b/actions/setup/js/unified_session_payload.cjs @@ -55,6 +55,8 @@ const AIC_RESOLVABLE_PHASES = new Set(["detection", "evals"]); const TOOL_FIELDS = { toolCallId: ["toolCallId", "tool_call_id", "tool_use_id"], toolName: ["toolName", "tool_name"], + messageId: ["messageId", "message_id"], + model: ["model"], mcpServerName: ["mcpServerName"], parentToolCallId: ["parentToolCallId"], stepIndex: ["stepIndex", "step_index"], @@ -862,9 +864,9 @@ function normalizeUnifiedSessionEvent(event, phase) { if (Array.isArray(errors)) data.errors = errors.filter(error => error && typeof error === "object" && !Array.isArray(error)).map(projectSafeOutputData); } const metadata = selectFields(event, { id: ["id"], parentId: ["parentId"], timestamp: ["timestamp", "ts", "time", "created_at"], agentId: ["agentId"], ephemeral: ["ephemeral"] }); - /** @type {any} */ const nativeMessage = event.message; - if (!Object.hasOwn(metadata, "timestamp") && nativeMessage?.timestamp !== undefined) metadata.timestamp = structuredClone(nativeMessage.timestamp); + const nativeTimestamp = (nativeMessage && typeof nativeMessage === "object" && "timestamp" in nativeMessage ? nativeMessage.timestamp : undefined) ?? source.messageMetadata?.timestamp; + if (!Object.hasOwn(metadata, "timestamp") && (typeof nativeTimestamp === "string" || typeof nativeTimestamp === "number")) metadata.timestamp = nativeTimestamp; return { type: event.type, ...metadata, diff --git a/actions/setup/js/unified_session_payload.test.cjs b/actions/setup/js/unified_session_payload.test.cjs index d6d6ef764b2..5b69d979711 100644 --- a/actions/setup/js/unified_session_payload.test.cjs +++ b/actions/setup/js/unified_session_payload.test.cjs @@ -21,6 +21,25 @@ import { failure as claudeFailures } from "./fixtures/claude_ci_sessions.cjs"; import { createSessionLookups } from "./grader_session.cjs"; describe("essential unified session payloads", () => { + it.each(["tool.execution_start", "tool.execution_update", "tool.execution_complete"])("retains tool/message correlation without publishing native envelopes (%s)", type => { + const original = { + type, + data: { + toolCallId: "call", + message_id: "message", + model: "served-model", + messageMetadata: { timestamp: 0, api: "fixture-api", usage: { input: 10 }, private: "OMITTED_NATIVE_CONTEXT" }, + }, + }; + const projected = normalizeUnifiedSessionEvent(original); + expect(projected).toEqual({ type, timestamp: 0, data: { toolCallId: "call", messageId: "message", model: "served-model" } }); + expect(normalizeUnifiedSessionEvent(projected)).toEqual(projected); + const [unified] = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", events: [original] }]); + expect(createSessionValidator("unified").event(unified)).toBe(true); + expect(JSON.stringify(unified)).not.toContain("OMITTED_NATIVE_CONTEXT"); + expect(original.data.messageMetadata.usage).toEqual({ input: 10 }); + }); + it("preserves observed Codex reconnect progress separately from harness attempts", () => { const native = parseEngineSession("Reconnecting... 2/5 (connection interrupted)\n", "codex").find(event => event.type === "session.result"); expect(native).toMatchObject({ reconnectAttempt: 2, maxReconnects: 5 }); diff --git a/docs/public/schemas/unified-session.schema.json b/docs/public/schemas/unified-session.schema.json index f33de7b8a19..cda7f480d36 100644 --- a/docs/public/schemas/unified-session.schema.json +++ b/docs/public/schemas/unified-session.schema.json @@ -322,6 +322,8 @@ "parentToolUseId": { "$ref": "#/definitions/JsonValue" }, "parentToolCallId": { "$ref": "#/definitions/JsonValue" }, "agentId": { "$ref": "#/definitions/JsonValue" }, + "messageId": { "$ref": "#/definitions/JsonValue" }, + "model": { "$ref": "#/definitions/JsonValue" }, "toolCallId": { "type": "string" }, "stepIndex": { "$ref": "#/definitions/SessionCount" }, "toolName": { "type": "string" }, @@ -356,6 +358,8 @@ "parentToolUseId": { "$ref": "#/definitions/JsonValue" }, "parentToolCallId": { "$ref": "#/definitions/JsonValue" }, "agentId": { "$ref": "#/definitions/JsonValue" }, + "messageId": { "$ref": "#/definitions/JsonValue" }, + "model": { "$ref": "#/definitions/JsonValue" }, "toolCallId": { "type": "string" }, "stepIndex": { "$ref": "#/definitions/SessionCount" }, "toolName": { "type": "string" }, @@ -392,6 +396,8 @@ "taskId": { "type": "string" }, "taskType": { "type": "string" }, "error": { "$ref": "#/definitions/JsonValue" }, + "messageId": { "$ref": "#/definitions/JsonValue" }, + "model": { "$ref": "#/definitions/JsonValue" }, "toolCallId": { "type": "string" }, "stepIndex": { "$ref": "#/definitions/SessionCount" }, "toolName": { "type": "string" }, diff --git a/docs/src/content/docs/reference/engines/pi.md b/docs/src/content/docs/reference/engines/pi.md index 1ae94e771dc..ec9e7e0b68a 100644 --- a/docs/src/content/docs/reference/engines/pi.md +++ b/docs/src/content/docs/reference/engines/pi.md @@ -54,6 +54,24 @@ The built-in `tools.playwright` integration uses `playwright-cli`; omit its `mod Pi resolves [model aliases](/gh-aw/reference/engines/#model-aliases) for the main model and for sub-agents with the same rules as Copilot: `model: copilot/opus` runs on the newest live Opus. Exact ids are checked against AWF `/reflect` before Pi starts, and an unlisted id fails with `model_unavailable`, producing the same unavailable-model failure issue as Copilot. When AWF's model discovery is incomplete, exact ids pass through and aliases fail with `catalog_unavailable`; sub-agents no longer fall back to Pi's built-in model list. +## Session artifacts + +The agent artifact contains native `pi-streaming.jsonl` evidence and the +normalized `agent-session.jsonl`. Conclusion publishes the essential event +projection in `usage/aw_session.jsonl`. Pi's observed message timestamps are +retained on canonical messages, reasoning and tool calls when no source-event +clock is present. Collection also recognizes clocks stored only in +`data.messageMetadata.timestamp` in older canonical artifacts. These clocks +use milliseconds or timezone-qualified ISO strings; missing clocks are not +filled with collection time. + +Unified tool lifecycle events retain supplied `messageId` and `model` alongside +`toolCallId`, allowing a tool call to be correlated with its originating model +response. Full native message metadata, provider envelopes and signatures remain +in the archival agent evidence rather than the unified projection. Child +messages remain scoped to their invocation, and child usage is not added to +parent session totals. + ## Durable runs > [!WARNING] 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..bb01df2033a 100644 --- a/docs/src/content/docs/specs/unified-agent-session-specification.md +++ b/docs/src/content/docs/specs/unified-agent-session-specification.md @@ -243,7 +243,7 @@ opaque because its essential fields are not defined by this specification. | Agent messages and reasoning | Exact `content`, without duplicate text blocks or the original message envelope. | | Agent session errors | Observed diagnostic fields, including `errorType`, `code`, and `statusCode`; absent status codes remain absent, and unrelated native error context is omitted. | | Agent policy refusals | Structured `reason`, exact available `content`, `policyCategory`, `explanation`, and streaming `partial` flag; no duplicated provider envelope. | -| Agent tool lifecycle | Correlation IDs, tool/server names, one `input` or `output` field, command, outcome/error signals, duration, and exit code. Background tool updates retain observed `taskId` and `taskType`; progress and task notifications do not imply a tool completion. | +| Agent tool lifecycle | Correlation IDs, including supplied `messageId`, the observed `model`, tool/server names, one `input` or `output` field, command, outcome/error signals, duration, and exit code. Background tool updates retain observed `taskId` and `taskType`; progress and task notifications do not imply a tool completion. | | Agent accounting | Turns, duration, cost, observed terminal `status` and `sourceType`, normalized `usage` including reported reasoning tokens, errors, and permission denials. | | Agent execution diagnostics | Unique harness/compiler `categories`, native `errorCodes` and `errorTypes`, and the observed final process `exitCode`; no diagnostic messages or duplicated engine envelopes. | | MCP | Server, direction, RPC/call/request IDs, method, tool name, duration, sizes, status, reason, and error code/message. Failed calls reporting HTTP 429 also retain `httpStatus: 429` and the sanitized `retryHint`, including retry durations or `Retry-After` seconds/HTTP-date. No RPC arguments, response bodies, echoed queries/URLs in rate-limit diagnostics, or error context. | @@ -369,7 +369,12 @@ timestamp values and MUST derive its ordering key using the source schema's units, not an epoch-magnitude heuristic. ISO timestamps MUST carry a timezone. Numeric Squid audit timestamps use Unix seconds; native agent timestamps and token-tracker audit `ts` values use milliseconds. Pi's observed -`message.timestamp` is also a supported source timestamp. ISO fractional seconds +`message.timestamp` is also a supported source timestamp, including its +`data.messageMetadata.timestamp` representation in saved canonical traces. +When the event has no source clock, the adapter or merger MUST retain that +observed message clock as `timestamp` before omitting the archival message +metadata. Explicit source clocks take precedence; ordering anchors are not +substitutes for missing native clocks. ISO fractional seconds and OTLP decimal-string Unix nanoseconds MUST be compared at nanosecond precision. `provenance.timestampMs` is an observed millisecond projection, not the exact ordering key; native timestamps retain their original precision. @@ -1717,6 +1722,12 @@ run metadata identifies Pi; it does not rewrite stored Actions artifacts. The adapter can discover a reported model from a finalized turn without inventing an earlier model observation. When Pi reports `responseModel`, canonical message and error observations use that served model; the canonical trace retains the requested `model` and native thinking levels as message metadata. Child request observations also preserve reported reasoning-output tokens without adding them to parent usage. Presentation aliases such as `bash` → `Bash` do not change canonical tool names. Sources lacking duration or cost do not supply zero duration or zero cost. +Pi message clocks survive reconciliation of `message_end`, `turn_end` and +`agent_end` snapshots onto streamed text, reasoning and tool calls. Supplied +tool `messageId` and `model` survive the unified projection; the full +`messageMetadata` envelope remains archival. These fields identify a message +and its model, not additional inference or usage observations. + ### 7.6 Custom engines **T-UAS-041 — Custom format detection.** The custom adapter MUST select a delegate from actual supported input signatures. Parseable JSON alone, any dot-containing type alone, a nonempty markdown result, or a delegate's fabricated initialization MUST NOT establish detection. Existing Claude-compatible and Codex fallback support MUST remain available for unprotected ingestion, and native canonical events MUST remain supported. From 4a8a1060fa6106b9d08bba9f6072ed81b649c4c4 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sun, 8 Nov 2026 19:58:15 -0800 Subject: [PATCH 2/2] Fix Pi clock reconciliation and validate archived timestamps Preserve source clock aliases, reconcile matching tool-result clocks, return published mapper events, and reuse ordering validation for message clock promotion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/pi_legacy_session.test.cjs | 20 ++++++++ actions/setup/js/pi_session.cjs | 50 ++++++++++++------- actions/setup/js/pi_session.test.cjs | 41 +++++++++++++-- .../setup/js/unified_session_order.test.cjs | 12 +++++ actions/setup/js/unified_session_payload.cjs | 3 +- .../setup/js/unified_session_payload.test.cjs | 20 ++++++++ docs/src/content/docs/reference/engines/pi.md | 7 +-- .../unified-agent-session-specification.md | 24 ++++++--- 8 files changed, 144 insertions(+), 33 deletions(-) diff --git a/actions/setup/js/pi_legacy_session.test.cjs b/actions/setup/js/pi_legacy_session.test.cjs index 895b65774e1..545d3f8bf18 100644 --- a/actions/setup/js/pi_legacy_session.test.cjs +++ b/actions/setup/js/pi_legacy_session.test.cjs @@ -20,6 +20,26 @@ const byType = (events, type) => events.filter(event => event.type === type); const parse = events => parsePiLog(events.map(JSON.stringify).join("\n")).logEntries; describe("historical Pi observations", () => { + it("reconciles the published legacy request when the mapper clones events, preserving mixed native clocks", () => { + const child = { role: "assistant", id: "child-answer", model: "fixture", usage: { input: 1, output: 0 } }; + const wrap = message => ({ type: "pi.subagent_event", data: { agent: "reader", invocation_id: "child", event: { message } } }); + const records = [ + wrap(child), + wrap({ ...child, usage: { input: 1, output: 2 } }), + { type: "message_end", message: { role: "toolResult", toolCallId: "call", timestamp: 3, content: "result" } }, + { type: "message_end", message: { role: "system", timestamp: 4, content: "system" } }, + { type: "message_end", message: { role: "assistant", timestamp: 5, content: [], errorMessage: "failure" } }, + ]; + const original = structuredClone(records); + const events = normalizePiObservations(records, event => ({ ...structuredClone(event), mapped: true })); + expect(byType(events, "subagent.request")).toHaveLength(1); + expect(byType(events, "subagent.request")[0]).toMatchObject({ mapped: true, data: { inputTokens: 1, outputTokens: 2 } }); + expect(byType(events, "tool.execution_complete")).toMatchObject([{ timestamp: 3 }]); + expect(byType(events, "session.info").find(event => event.data.sourceType === "message_snapshot")).toMatchObject({ timestamp: 4 }); + expect(byType(events, "session.error")).toMatchObject([{ timestamp: 5 }]); + expect(records).toEqual(original); + }); + it.each(["assistant", "toolResult"])("maps a %s snapshot as metadata, not another message or usage report", role => { const usage = { input: 0, output: 2, cacheRead: 0, cost: { total: 0 } }; const snapshot = { diff --git a/actions/setup/js/pi_session.cjs b/actions/setup/js/pi_session.cjs index 8551ca351df..9248c362edd 100644 --- a/actions/setup/js/pi_session.cjs +++ b/actions/setup/js/pi_session.cjs @@ -108,7 +108,7 @@ function messageIdentity(message) { /** @param {SessionEvent} event @param {any} message */ function retainMessageTimestamp(event, message) { - if (event.timestamp === undefined && (typeof message?.timestamp === "string" || typeof message?.timestamp === "number")) event.timestamp = message.timestamp; + if (["timestamp", "ts", "time", "created_at"].every(key => event[key] === undefined) && (typeof message?.timestamp === "string" || typeof message?.timestamp === "number")) event.timestamp = message.timestamp; } /** @returns {MessageState} */ @@ -149,7 +149,9 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { const executionEnds = new Set(records.filter(r => r.type === "tool_execution_end" && r.toolCallId !== undefined).map(r => r.toolCallId)); const calls = new Map(); const executionStarts = new Set(); - const completions = new Set(); + /** @type {Map} */ + const completions = new Map(); + const completionMessages = new Map(); /** @type {Map} */ const messageStates = new Map(); /** @type {Array<{message: any, state: MessageState}>} */ @@ -167,8 +169,9 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { const emit = (raw, type, data, metadata = {}) => { const event = createSessionEvent(raw, type, data); Object.assign(event, metadata); - events.push(legacyObservation ? mapLegacyEvent(event) : event); - return event; + const mappedEvent = legacyObservation ? mapLegacyEvent(event) : event; + events.push(mappedEvent); + return mappedEvent; }; const invocationIdentity = (raw, dispatch) => { const agentName = raw.agent ?? raw.agentName ?? "unknown"; @@ -301,18 +304,26 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { const emitCompletion = (raw, message) => { const id = message.toolCallId; - if (id !== undefined && (executionEnds.has(id) || completions.has(id))) return; - if (id !== undefined) completions.add(id); - retainMessageTimestamp( - emit(raw, "tool.execution_complete", { - ...message, - toolCallId: id, - toolName: message.toolName, - output: message.content, - success: message.isError === true || isPiResultError(message) ? false : message.isError === false ? true : undefined, - }), - message - ); + if (id !== undefined) { + const previous = completions.get(id); + if (previous) { + retainMessageTimestamp(previous, message); + return; + } + if (executionEnds.has(id)) { + if (typeof completionMessages.get(id)?.timestamp !== "string" && typeof completionMessages.get(id)?.timestamp !== "number") completionMessages.set(id, message); + return; + } + } + const event = emit(raw, "tool.execution_complete", { + ...message, + toolCallId: id, + toolName: message.toolName, + output: message.content, + success: message.isError === true || isPiResultError(message) ? false : message.isError === false ? true : undefined, + }); + retainMessageTimestamp(event, message); + if (id !== undefined) completions.set(id, event); }; const emitMessage = (raw, message, fallback) => { @@ -498,8 +509,7 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { ); } else if (raw.type === "tool_execution_end") { if (raw.toolCallId !== undefined && completions.has(raw.toolCallId)) continue; - if (raw.toolCallId !== undefined) completions.add(raw.toolCallId); - emit(raw, "tool.execution_complete", { + const event = emit(raw, "tool.execution_complete", { toolCallId: raw.toolCallId, toolName: raw.toolName, parentToolCallId: raw.parentToolCallId, @@ -509,6 +519,10 @@ function transformPiV3Entries(records, mapLegacyEvent = event => event) { success: raw.isError === true || raw.error != null || isPiResultError(raw.result) ? false : raw.isError === false ? true : undefined, durationMs: isMetric(raw.durationMs) ? raw.durationMs : undefined, }); + if (raw.toolCallId !== undefined) { + completions.set(raw.toolCallId, event); + retainMessageTimestamp(event, completionMessages.get(raw.toolCallId)); + } } else if (raw.type === "tool_execution_update") emit(raw, "tool.execution_update", { toolCallId: raw.toolCallId, toolName: raw.toolName, parentToolCallId: raw.parentToolCallId, input: raw.args, output: raw.partialResult, partial: true }); else if (Object.hasOwn(PI_SESSION_EVENT_TYPES, raw.type)) { diff --git a/actions/setup/js/pi_session.test.cjs b/actions/setup/js/pi_session.test.cjs index 1d177445bb4..52a4d096724 100644 --- a/actions/setup/js/pi_session.test.cjs +++ b/actions/setup/js/pi_session.test.cjs @@ -54,19 +54,54 @@ describe("Pi CI stream regressions", () => { expect(records).toEqual(original); }); - it("preserves source clocks over embedded message clocks and never invents a missing clock", () => { + it.each(["timestamp", "ts", "time", "created_at"])("preserves the source %s clock over embedded message clocks and never invents a missing clock", clock => { const message = { role: "assistant", timestamp: 10, content: [{ type: "text", text: "Sanitized answer" }] }; const events = transformPiV3Entries([ - { type: "message_update", timestamp: 5, assistantMessageEvent: { type: "text_delta", delta: "Sanitized answer" } }, + { type: "message_update", [clock]: 5, assistantMessageEvent: { type: "text_delta", delta: "Sanitized answer" } }, { type: "message_end", timestamp: 20, message }, ]); expect(events).toHaveLength(1); - expect(events[0].timestamp).toBe(5); + expect(events[0][clock]).toBe(5); + if (clock !== "timestamp") expect(events[0]).not.toHaveProperty("timestamp"); expect(events[0].data.messageMetadata.timestamp).toBe(10); + const unified = mergeSessionSources([ + { component: "agent", phase: "agent", path: "pi-streaming.jsonl", events }, + { component: "other", phase: "agent", path: "other.jsonl", events: [{ type: "session.info", timestamp: 7, data: { content: "later" } }] }, + ]); + expect(unified[0]).toMatchObject({ type: "assistant.message", provenance: { timestampMs: 5 } }); const [untimed] = transformPiV3Entries([{ type: "message_end", message: { ...message, timestamp: undefined } }]); expect(untimed).not.toHaveProperty("timestamp"); }); + it("recovers the sampled tool-result clock onto its single observed execution completion", () => { + const original = structuredClone(success); + const events = parse(success).logEntries; + expect(byType(events, "tool.execution_complete")).toMatchObject([{ timestamp: 3 }]); + expect(byType(events, "tool.execution_complete")).toHaveLength(1); + const unified = mergeSessionSources([{ component: "agent", phase: "agent", path: "pi-streaming.jsonl", events }]); + expect(byType(unified, "tool.execution_complete")).toMatchObject([{ timestamp: 3, provenance: { timestampMs: 3 } }]); + expect(success).toEqual(original); + }); + + it.each([false, true])("reconciles repeated tool-result clocks when the execution end arrives first=%s", endFirst => { + const end = { type: "tool_execution_end", toolCallId: "call", result: { content: "observed execution" }, isError: false }; + const result = { type: "message_end", message: { role: "toolResult", toolCallId: "call", timestamp: 3, content: "snapshot" } }; + const records = endFirst ? [end, result, result] : [result, result, end]; + const [event] = transformPiV3Entries(records); + expect(transformPiV3Entries(records)).toHaveLength(1); + expect(event).toMatchObject({ timestamp: 3, data: { output: end.result, success: true } }); + }); + + it.each(["timestamp", "ts", "time", "created_at"])("does not replace an execution completion's explicit %s clock with its result clock", clock => { + const events = transformPiV3Entries([ + { type: "tool_execution_end", [clock]: 0, toolCallId: "call", result: { content: "observed execution" } }, + { type: "message_end", message: { role: "toolResult", toolCallId: "call", timestamp: 3, content: "snapshot" } }, + ]); + expect(events).toHaveLength(1); + expect(events[0][clock]).toBe(0); + if (clock !== "timestamp") expect(events[0]).not.toHaveProperty("timestamp"); + }); + it("retains child, tool-result and provider-error clocks without duplicating snapshots or parent usage", () => { const child = { role: "assistant", responseId: "child-response", model: "fixture-model", timestamp: 3, content: [{ type: "text", text: "Child answer" }], usage: { input: 5, output: 2 } }; const records = [ diff --git a/actions/setup/js/unified_session_order.test.cjs b/actions/setup/js/unified_session_order.test.cjs index 171abaffb5f..8f83224e809 100644 --- a/actions/setup/js/unified_session_order.test.cjs +++ b/actions/setup/js/unified_session_order.test.cjs @@ -34,6 +34,18 @@ describe("Unified trace ordering", () => { expect(sessionTimestamp({ type: "assistant.message", data: { content: "untimed" } })).toBeUndefined(); }); + it.each(["yesterday", "2026-11-09T00:27:23.729", "2026-02-30T00:00:00Z", 8640000000000001, { value: 0 }])("keeps invalid archived clocks untimed and source ordered (%j)", timestamp => { + const saved = { type: "tool.execution_start", data: { toolCallId: "call", messageMetadata: { timestamp } } }; + const original = structuredClone(saved); + const merged = mergeSessionSources([source("agent", [event("start", 10), saved, event("end", 20)]), source("mcp", [event("rpc", 15)])]); + expect(merged.map(record => record.id ?? record.data.toolCallId)).toEqual(["start", "call", "rpc", "end"]); + expect(merged[1]).not.toHaveProperty("timestamp"); + expect(merged[1].provenance).not.toHaveProperty("timestampMs"); + expect(sessionTimestampNs(merged[1])).toBeUndefined(); + expect(validateSession(jsonl(merged))).toBe(5); + expect(saved).toEqual(original); + }); + it.each([[], [source("agent", [])], [source("agent", []), source("otel", [], "nanoseconds")]].map(sources => [sources]))("accepts empty source collections without inventing observations: %j", sources => { expect(mergeSessionSources(sources)).toEqual([]); }); diff --git a/actions/setup/js/unified_session_payload.cjs b/actions/setup/js/unified_session_payload.cjs index 345ef6d655d..4f34f254378 100644 --- a/actions/setup/js/unified_session_payload.cjs +++ b/actions/setup/js/unified_session_payload.cjs @@ -13,6 +13,7 @@ const { redactWebhookPayloads } = require("./webhook_context.cjs"); const { isErrorIdentifier, normalizeEngineHarness } = require("./routing_analytics.cjs"); const { projectSafeOutputData } = require("./safe_output_outcomes.cjs"); const { normalizeHarnessRetryRecord, normalizeHarnessAttemptEvent, normalizeHarnessDecisionData } = require("./harness_retry_record.cjs"); +const { sessionTimestampNs } = require("./unified_session_order.cjs"); /** AWF failed-request origins: provider response versus AWF-originated rejection. */ const ERROR_SOURCES = new Set(["upstream", "awf"]); @@ -867,7 +868,7 @@ function normalizeUnifiedSessionEvent(event, phase) { const metadata = selectFields(event, { id: ["id"], parentId: ["parentId"], timestamp: ["timestamp", "ts", "time", "created_at"], agentId: ["agentId"], ephemeral: ["ephemeral"] }); const nativeMessage = event.message; const nativeTimestamp = (nativeMessage && typeof nativeMessage === "object" && "timestamp" in nativeMessage ? nativeMessage.timestamp : undefined) ?? source.messageMetadata?.timestamp; - if (!Object.hasOwn(metadata, "timestamp") && (typeof nativeTimestamp === "string" || typeof nativeTimestamp === "number")) metadata.timestamp = nativeTimestamp; + if (!Object.hasOwn(metadata, "timestamp") && sessionTimestampNs({ timestamp: nativeTimestamp }) !== undefined) metadata.timestamp = nativeTimestamp; return { type: event.type, ...metadata, diff --git a/actions/setup/js/unified_session_payload.test.cjs b/actions/setup/js/unified_session_payload.test.cjs index f314bddcd4f..efa4a495f6b 100644 --- a/actions/setup/js/unified_session_payload.test.cjs +++ b/actions/setup/js/unified_session_payload.test.cjs @@ -19,8 +19,28 @@ import { dynamicWorkflow } from "./fixtures/claude_dynamic_workflow.cjs"; import { DYNAMIC_WORKFLOW_EVENT_TYPES } from "./dynamic_workflow_session.cjs"; import { failure as claudeFailures } from "./fixtures/claude_ci_sessions.cjs"; import { createSessionLookups } from "./grader_session.cjs"; +import { sessionTimestampNs } from "./unified_session_order.cjs"; describe("essential unified session payloads", () => { + it.each(["yesterday", "2026-11-09T00:27:23.729", "2026-02-30T00:00:00Z", 8640000000000001, Infinity, { value: 0 }])("does not promote invalid native or archived message clocks %j", timestamp => { + for (const original of [ + { type: "tool.execution_start", data: { toolCallId: "call", messageMetadata: { timestamp } } }, + { type: "assistant.message", data: { content: "untimed" }, message: { role: "assistant", content: "untimed", timestamp } }, + ]) { + const snapshot = structuredClone(original); + const projected = normalizeUnifiedSessionEvent(original); + expect(sessionTimestampNs(original)).toBeUndefined(); + expect(projected).not.toHaveProperty("timestamp"); + expect(sessionTimestampNs(projected)).toBeUndefined(); + expect(normalizeUnifiedSessionEvent(projected)).toEqual(projected); + const [unified] = mergeSessionSources([{ component: "agent", phase: "agent", path: "agent-session.jsonl", events: [original] }]); + expect(unified).not.toHaveProperty("timestamp"); + expect(unified.provenance).not.toHaveProperty("timestampMs"); + expect(createSessionValidator("unified").event(unified)).toBe(true); + expect(original).toEqual(snapshot); + } + }); + it.each(["tool.execution_start", "tool.execution_update", "tool.execution_complete"])("retains tool/message correlation without publishing native envelopes (%s)", type => { const original = { type, diff --git a/docs/src/content/docs/reference/engines/pi.md b/docs/src/content/docs/reference/engines/pi.md index ec9e7e0b68a..01df83f4849 100644 --- a/docs/src/content/docs/reference/engines/pi.md +++ b/docs/src/content/docs/reference/engines/pi.md @@ -61,9 +61,10 @@ normalized `agent-session.jsonl`. Conclusion publishes the essential event projection in `usage/aw_session.jsonl`. Pi's observed message timestamps are retained on canonical messages, reasoning and tool calls when no source-event clock is present. Collection also recognizes clocks stored only in -`data.messageMetadata.timestamp` in older canonical artifacts. These clocks -use milliseconds or timezone-qualified ISO strings; missing clocks are not -filled with collection time. +`data.messageMetadata.timestamp` in older canonical artifacts. Collection +promotes only finite, in-range milliseconds or calendar-valid timezone-qualified +ISO strings from archival message metadata. Invalid archival clocks are not +promoted; missing clocks are not filled with collection time. Unified tool lifecycle events retain supplied `messageId` and `model` alongside `toolCallId`, allowing a tool call to be correlated with its originating model 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 1da7a975180..008ded57b7b 100644 --- a/docs/src/content/docs/specs/unified-agent-session-specification.md +++ b/docs/src/content/docs/specs/unified-agent-session-specification.md @@ -3,9 +3,9 @@ title: Unified Agent Session Specification description: Draft contract for canonical traces, full unified artifacts, and bounded compact unified sessions. sidebar: order: 1365 -version: "2.7.0" +version: "2.8.0" status: Draft -publication_date: "2026-10-30" +publication_date: "2026-11-09" editors: - name: GitHub Agentic Workflows Team organization: GitHub @@ -13,9 +13,9 @@ editors: # Unified Agent Session Specification -**Version**: 2.7.0
+**Version**: 2.8.0
**Status**: Draft
-**Publication Date**: 2026-10-30
+**Publication Date**: 2026-11-09
**Editors**: GitHub Agentic Workflows Team (GitHub)
**This Version**: [unified-agent-session-specification](/gh-aw/specs/unified-agent-session-specification/)
**Latest Version**: This document @@ -30,7 +30,7 @@ This specification defines the session traces used by GitHub Agentic Workflows: This is a **GitHub Agentic Workflows project specification**, written using W3C-inspired document conventions. It is **not an official W3C standard**, W3C publication, or W3C-endorsed recommendation. -Version 2.7.0 is a draft governed by the project's normal review process. It may be updated, replaced, or superseded. The accompanying implementation and regression suites exercise this contract, including the sampled CI sessions identified in Section 9.4. This is not a blanket declaration of conformance for every engine version or source format. Section 10 records historical pre-implementation gaps, not current defects. Approval and ongoing compliance testing remain project responsibilities. +Version 2.8.0 is a draft governed by the project's normal review process. It may be updated, replaced, or superseded. The accompanying implementation and regression suites exercise this contract, including the sampled CI sessions identified in Section 9.4. This is not a blanket declaration of conformance for every engine version or source format. Section 10 records historical pre-implementation gaps, not current defects. Approval and ongoing compliance testing remain project responsibilities. The specification version belongs to this document. The unified file's leading `session.format` record carries an independent numeric serialization-format @@ -371,10 +371,12 @@ Numeric Squid audit timestamps use Unix seconds; native agent timestamps and token-tracker audit `ts` values use milliseconds. Pi's observed `message.timestamp` is also a supported source timestamp, including its `data.messageMetadata.timestamp` representation in saved canonical traces. -When the event has no source clock, the adapter or merger MUST retain that +When the event has no source clock, the unified projection MUST retain a valid observed message clock as `timestamp` before omitting the archival message -metadata. Explicit source clocks take precedence; ordering anchors are not -substitutes for missing native clocks. ISO fractional seconds +metadata. Promotion MUST use the ordering parser's timezone, calendar and +numeric-range validation; invalid archival message clocks MUST NOT be promoted. +Explicit source clocks take precedence and retain their native values; ordering +anchors are not substitutes for missing native clocks. ISO fractional seconds and OTLP decimal-string Unix nanoseconds MUST be compared at nanosecond precision. `provenance.timestampMs` is an observed millisecond projection, not the exact ordering key; native timestamps retain their original precision. @@ -2668,6 +2670,12 @@ Native IDs can collide, timestamps can be out of order, and a trace can contain ## 13. Change Log (Informative) +### Version 2.8.0 - Draft (2026-11-09) + +- Retained valid observed Pi message clocks across native and saved canonical traces, with explicit source-clock precedence and shared ordering validation (T-UAS-056). +- Preserved tool/message/model correlation and supplied structured tool content in the essential unified projection while keeping native envelopes archival. +- Retained serialization-format version 1. + ### Version 2.7.0 - Draft (2026-10-30) - Added observed harness and startup attempts, partial lifecycle metadata, and evidence-based event correlation (T-UAS-087 through T-UAS-089).