From b2be0f636cbacc965fdc9c16daa3ecb88f5f86f7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 21:42:31 +0800 Subject: [PATCH 01/13] fix(session-analysis): accept audited dsh rc8 events Extend the existing dsh-v1 format-0 reader with strict rc.8 interrupted-assistant and team-event validation while keeping team records account-only. Add native-derived regression fixtures and document the audited rc.7/rc.8 evidence slice. Validated with the focused DSH suites, the full session suites, npm test, generated-code checks, workspace package tests, documentation links, and pack verification. Co-authored-by: Codex (GPT 5.6 Sol) --- docs/adapters/README.md | 11 +- docs/docs/hosts/adapter-matrix.md | 15 +- scripts/session-analysis/platforms/dsh.mjs | 194 +++++++++++++++++- test/sessions/dsh-fixtures.mjs | 91 ++++++++ .../session-analysis-dsh-discovery.test.mjs | 67 ++++++ .../session-analysis-dsh-provider.test.mjs | 52 +++++ 6 files changed, 417 insertions(+), 13 deletions(-) diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 50a5c328..71e959d4 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -44,7 +44,7 @@ project `.kimi-code/skills/`), then runs `/skill:better-harness`. | Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | | Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | -| DeepSeek Harness (DSH) | Partial session-evidence adapter (developer preview) | none / unavailable | unavailable | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for DSH `dsh-v0.1.0-rc.7` session format `0`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | unavailable | read-only `node scripts/session-analysis.mjs sources --platform dsh --workspace [--dsh-home ]` or `node scripts/session-analysis.mjs facts --platform dsh --workspace [--dsh-home ]` | +| DeepSeek Harness (DSH) | Partial session-evidence adapter (developer preview) | none / unavailable | unavailable | `scripts/session-analysis/platforms/dsh.mjs`; `dsh-v1` for the audited format-0 session-evidence slice from DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, raw `.jsonl` and feature-detected `.jsonl.zstd` | unavailable; no report route | unavailable | read-only `node scripts/session-analysis.mjs sources --platform dsh --workspace [--dsh-home ]` or `node scripts/session-analysis.mjs facts --platform dsh --workspace [--dsh-home ]` | ## Read-only Plugin Lifecycle @@ -198,9 +198,12 @@ edit host settings, or register an `apply` path. `/sessions`. Discovery is read-only and accepts only the fixed nested `session.jsonl` or `session.jsonl.zstd` layout. Workspace qualification uses only the format-0 header's absolute `cwd`; the lossy project directory is not - workspace evidence. Better Harness reports adapter metadata `dsh-v1` and - supports native DSH session format `0` pinned to `dsh-v0.1.0-rc.7`. The host - is registered only for the `sessionAnalysis` capability. + workspace evidence. Better Harness reports adapter metadata `dsh-v1`; its + format-0 session-evidence slice is validated against `dsh-v0.1.0-rc.7` and + `dsh-v0.1.0-rc.8`, including RC8 interrupted assistant messages and the + required team-event vocabulary. The host is registered only for the + `sessionAnalysis` capability; team events are validated and accounted, not + projected as team analytics. Known-but-unsupported events and unknown ignorable events are explicitly accounted for. Unknown required events, malformed records, identity drift, and unsupported versions fail closed; an open trailing turn remains diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 65b59ba8..5f524e63 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -121,12 +121,15 @@ smoke is observed. ### DeepSeek Harness (DSH) {#deepseek-harness-dsh} DSH coverage is a developer-preview, JSONL-only session slice, with Better -Harness adapter metadata `dsh-v1` and native session format `0` pinned to DSH -`dsh-v0.1.0-rc.7`. Home resolution is strictly `--dsh-home` over `DSH_HOME` -over `~/.dsh`; the only source root is `/sessions`. The adapter reads the -fixed nested `session.jsonl` or `session.jsonl.zstd` layout without writing or -repairing artifacts, and it qualifies a workspace only from the header's -absolute `cwd`. DSH is registered only for the `sessionAnalysis` capability. +Harness adapter metadata `dsh-v1`. Its format-0 session-evidence slice is +validated against DSH `dsh-v0.1.0-rc.7` and `dsh-v0.1.0-rc.8`, including RC8 +interrupted assistant messages and required team-event vocabulary. Team events +are validated and accounted, not projected as team analytics. Home resolution +is strictly `--dsh-home` over `DSH_HOME` over `~/.dsh`; the only source root is +`/sessions`. The adapter reads the fixed nested `session.jsonl` or +`session.jsonl.zstd` layout without writing or repairing artifacts, and it +qualifies a workspace only from the header's absolute `cwd`. DSH is registered +only for the `sessionAnalysis` capability. Compressed artifacts are concatenated independently checksummed Zstandard frames and are validated and decompressed one complete frame at a time. The diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index 5b622467..90bcc4e4 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -36,6 +36,7 @@ const VALIDATED_KNOWN_UNSUPPORTED_TYPES = new Set([ "hook/result", "llm/retry", "llm/retry-started", "permission/preset", "plan/mode", "request/context", "request/header", "sandbox/mode", "session/end-seed", "session/title", "session/title-llm-request", "subagent/descriptor", "todo/write", "tool-workflow/agent-end", + "team/member", "team/message/delivered", "team/message/queued", "team/task", "tool-workflow/agent-start", "tool-workflow/run-end", "tool-workflow/run-start", "tool/code-dispatch", "tool/code-dispatch-start", "schedule/change", "web/deepseek-search-llm-request", ]); @@ -48,6 +49,7 @@ const KNOWN_EVENT_TYPES = new Set([ "llm/retry-started", "permission/preset", "plan/mode", "request/context", "request/header", "sandbox/mode", "schedule/change", "session/end-seed", "session/title", "session/title-llm-request", "step/end", "step/start", "subagent/descriptor", "todo/write", + "team/member", "team/message/delivered", "team/message/queued", "team/task", "tool-workflow/agent-end", "tool-workflow/agent-start", "tool-workflow/run-end", "tool-workflow/run-start", "tool/call", "tool/code-dispatch", "tool/code-dispatch-start", "tool/result", "turn/end", "turn/start", "user/message", "web/deepseek-search-llm-request", @@ -364,6 +366,96 @@ function validateContentBlock(block) { } } +const TEAM_CORE_CONTENT_BLOCK_TYPES = new Set(["text", "reasoning", "image", "tool-call", "tool-result"]); + +function validateTeamContentBlock(block) { + if (!plain(block) || !nonemptyString(block.type)) fail("DSH_EVENT_SHAPE_DRIFT"); + switch (block.type) { + case "text": + case "reasoning": + if (!exactKeys(block, ["type", "text"]) || typeof block.text !== "string") fail("DSH_EVENT_SHAPE_DRIFT"); + break; + case "image": { + if (!exactKeys(block, ["type", "attachment"]) || !exactKeys(block.attachment, + ["attachmentId", "mediaType", "bytes", "width", "height"], ["name"])) fail("DSH_EVENT_SHAPE_DRIFT"); + const attachment = block.attachment; + if (!nonemptyString(attachment.attachmentId) + || !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(attachment.mediaType) + || !safeNonnegative(attachment.bytes) || !safePositive(attachment.width) || !safePositive(attachment.height) + || (Object.hasOwn(attachment, "name") && typeof attachment.name !== "string")) { + fail("DSH_EVENT_SHAPE_DRIFT"); + } + break; + } + case "tool-call": + if (!exactKeys(block, ["type", "id", "name", "arguments"]) || !nonemptyString(block.id) + || typeof block.name !== "string" || typeof block.arguments !== "string") fail("DSH_EVENT_SHAPE_DRIFT"); + break; + case "tool-result": + if (!exactKeys(block, ["type", "toolCallId", "content"], ["isError"]) + || !nonemptyString(block.toolCallId) || !Array.isArray(block.content) + || (Object.hasOwn(block, "isError") && typeof block.isError !== "boolean")) fail("DSH_EVENT_SHAPE_DRIFT"); + for (const nested of block.content) validateTeamContentBlock(nested); + break; + default: + if (TEAM_CORE_CONTENT_BLOCK_TYPES.has(block.type) || !isDshJsonValue(block)) fail("DSH_EVENT_SHAPE_DRIFT"); + } +} + +function validTeamTaskId(value) { + if (!nonemptyString(value)) return false; + const match = /^task-(\d+)$/u.exec(value); + return match === null || Number.isSafeInteger(Number(match[1])); +} + +function validateTeamEvent(event) { + const data = event.data; + if (!plain(data) || !safeNonnegative(data.version) || !nonemptyString(data.teamId)) { + fail("DSH_EVENT_SHAPE_DRIFT"); + } + if (data.version !== 1) return; + if (event.type === "team/member") { + if (!exactKeys(data, ["version", "teamId", "member"]) || !exactKeys(data.member, + ["id", "name", "description", "provider", "context", "phase"], ["error"])) { + fail("DSH_EVENT_SHAPE_DRIFT"); + } + const member = data.member; + if (!nonemptyString(member.id) || [member.name, member.description, member.provider] + .some((value) => typeof value !== "string") || !["fresh", "fork"].includes(member.context) + || !["provisioning", "active", "failed"].includes(member.phase) + || (Object.hasOwn(member, "error") && typeof member.error !== "string")) fail("DSH_EVENT_SHAPE_DRIFT"); + return; + } + if (event.type === "team/task") { + if (!exactKeys(data, ["version", "teamId", "task"]) || !exactKeys(data.task, + ["id", "revision", "subject", "description", "status", "blockedBy", "writeScopes"], ["ownerId"])) { + fail("DSH_EVENT_SHAPE_DRIFT"); + } + const task = data.task; + if (!validTeamTaskId(task.id) || !safePositive(task.revision) + || typeof task.subject !== "string" || typeof task.description !== "string" + || !["pending", "in_progress", "completed", "deleted"].includes(task.status) + || (Object.hasOwn(task, "ownerId") && !nonemptyString(task.ownerId)) + || !Array.isArray(task.blockedBy) || task.blockedBy.some((id) => !validTeamTaskId(id)) + || !Array.isArray(task.writeScopes) || task.writeScopes.some((scope) => typeof scope !== "string")) { + fail("DSH_EVENT_SHAPE_DRIFT"); + } + return; + } + if (event.type === "team/message/queued") { + if (!exactKeys(data, ["version", "teamId", "message"]) || !exactKeys(data.message, + ["id", "senderId", "senderName", "targetId", "delivery", "content"])) fail("DSH_EVENT_SHAPE_DRIFT"); + const message = data.message; + if (![message.id, message.senderId, message.targetId].every(nonemptyString) + || typeof message.senderName !== "string" || !["quiet", "wakeup"].includes(message.delivery) + || !Array.isArray(message.content)) fail("DSH_EVENT_SHAPE_DRIFT"); + for (const block of message.content) validateTeamContentBlock(block); + return; + } + if (!exactKeys(data, ["version", "teamId", "messageId", "targetId"]) + || !nonemptyString(data.messageId) || !nonemptyString(data.targetId)) fail("DSH_EVENT_SHAPE_DRIFT"); +} + function validateMessage(message, role) { if (!exactKeys(message, ["id", "role", "content", "source"]) || message.role !== role || typeof message.id !== "string" @@ -567,11 +659,12 @@ function validateSupportedEvent(event) { validateUserSource(data.source); break; case "assistant/message": - if (!exactKeys(data, ["turn", "step", "message"], ["usage"]) || !safePositive(data.turn) + if (!exactKeys(data, ["turn", "step", "message"], ["usage", "interrupted"]) || !safePositive(data.turn) || !safePositive(data.step)) fail("DSH_EVENT_SHAPE_DRIFT"); validateMessage(data.message, "assistant"); validateAssistantSource(data.message.source); if (Object.hasOwn(data, "usage")) validateTokenUsage(data.usage); + if (Object.hasOwn(data, "interrupted") && data.interrupted !== true) fail("DSH_EVENT_SHAPE_DRIFT"); break; case "assistant/chunk": if (!exactKeys(data, ["turn", "step", "chunk"]) || !safePositive(data.turn) @@ -702,6 +795,12 @@ function validateKnownUnsupportedEvent(event) { case "request/header": validateRequestHeader(data); break; + case "team/member": + case "team/task": + case "team/message/queued": + case "team/message/delivered": + validateTeamEvent(event); + break; case "session/end-seed": if (!exactKeys(data, [])) fail("DSH_EVENT_SHAPE_DRIFT"); break; @@ -1374,6 +1473,81 @@ function applyCompactionFold(state, event, openTurn, durableSuffixStart) { state.open = null; } +function validateTeamTaskGraph(current, candidate) { + const tasks = new Map(current); + tasks.set(candidate.id, candidate); + for (const task of tasks.values()) { + if (task.status === "deleted") continue; + const seen = new Set(); + for (const blockerId of task.blockedBy) { + if (blockerId === task.id || seen.has(blockerId)) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + const blocker = tasks.get(blockerId); + if (blocker === undefined || blocker.status === "deleted") fail("DSH_EVENT_RELATIONSHIP_INVALID"); + seen.add(blockerId); + } + } + const visiting = new Set(); + const visited = new Set(); + const visit = (id) => { + if (visiting.has(id)) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + if (visited.has(id)) return; + const task = tasks.get(id); + if (task === undefined || task.status === "deleted") return; + visiting.add(id); + for (const blockerId of task.blockedBy) visit(blockerId); + visiting.delete(id); + visited.add(id); + }; + for (const task of tasks.values()) visit(task.id); +} + +function applyTeamFold(state, event) { + if (!["team/member", "team/task", "team/message/queued", "team/message/delivered"].includes(event.type)) { + return; + } + const data = event.data; + if (data.version !== 1) { + if (data.teamId === state.id) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + return; + } + if (data.teamId !== state.id) return; + if (event.type === "team/member") { + const member = data.member; + const prior = state.members.get(member.id); + const named = state.memberIdsByName.get(member.name); + if (named !== undefined && named !== member.id) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + if (prior === undefined) { + if (member.phase !== "provisioning") fail("DSH_EVENT_RELATIONSHIP_INVALID"); + state.memberIdsByName.set(member.name, member.id); + } else if (prior.name !== member.name || prior.provider !== member.provider || prior.context !== member.context + || prior.phase !== "provisioning" || member.phase === "provisioning") { + fail("DSH_EVENT_RELATIONSHIP_INVALID"); + } + state.members.set(member.id, member); + return; + } + if (event.type === "team/task") { + const task = data.task; + const prior = state.tasks.get(task.id); + if ((prior === undefined && task.revision !== 1) + || (prior !== undefined && task.revision !== prior.revision + 1)) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + validateTeamTaskGraph(state.tasks, task); + state.tasks.set(task.id, task); + return; + } + if (event.type === "team/message/queued") { + const message = data.message; + if (state.messages.has(message.id)) fail("DSH_EVENT_RELATIONSHIP_INVALID"); + state.messages.set(message.id, message); + return; + } + const message = state.messages.get(data.messageId); + if (message === undefined || message.targetId !== data.targetId || state.delivered.has(data.messageId)) { + fail("DSH_EVENT_RELATIONSHIP_INVALID"); + } + state.delivered.add(data.messageId); +} + function validateSequence(events, header) { let nextTurn = 1; let nextStep = 1; @@ -1394,6 +1568,14 @@ function validateSequence(events, header) { const retryScheduled = new Map(); const retryStarted = new Set(); const goal = { goal: null, roundsStarted: 0, createdAt: null, updatedAt: null, seenIds: new Set() }; + const team = { + id: header.id, + members: new Map(), + memberIdsByName: new Map(), + tasks: new Map(), + messages: new Map(), + delivered: new Set(), + }; const compaction = { open: null, pendingShadow: null }; let latestRequestHeader = null; let ownDescriptorSeen = false; @@ -1411,6 +1593,7 @@ function validateSequence(events, header) { if (event.type === "agent/inbox/spliced" && index >= durableSuffixStart) applyInboxSplice(data, queues); if (event.type === "schedule/change" && index >= durableSuffixStart) applyScheduleChange(data, schedules); applyGoalFold(goal, event); + applyTeamFold(team, event); if (event.type === "user/message" && data.source?.kind === "user" && textFromContent(data.content).trim().length > 0) directHumanSeqs.push(event.seq); if (event.type === "approval/asked") { @@ -1912,16 +2095,18 @@ function normalizeDshEvents(event, sourceRef, options = {}) { const safeText = boundedPrivateText(rawText, 8_000); const model = safeLabel(event.data.message.source.model); const provider = safeLabel(event.data.message.source.provider); + const interrupted = event.data.interrupted === true; const assistant = { ...base, type: "assistant", category: "assistant", model, modelProvider: provider, + ...(interrupted ? { interrupted: true, incomplete: true } : {}), contentLength: rawText.length, userVisibleAssistantMessage: rawText.length > 0, evidenceRef: normalizedEvidenceRef(sourceRef, event, "assistant"), - summary: "assembled assistant message", + summary: interrupted ? "interrupted assembled assistant message" : "assembled assistant message", }; if (includeGate(options, "includeContent", "include-content") && safeText) assistant.content = safeText; const events = [assistant]; @@ -1934,8 +2119,11 @@ function normalizeDshEvents(event, sourceRef, options = {}) { modelProvider: provider, modelUsage: normalizedUsage(event.data.usage), usageFieldsObserved: true, + ...(interrupted ? { interrupted: true, incomplete: true } : {}), evidenceRef: normalizedEvidenceRef(sourceRef, event, "model.response.completed"), - summary: "DeepSeek Harness model response completed", + summary: interrupted + ? "DeepSeek Harness interrupted model response usage" + : "DeepSeek Harness model response completed", }); } return events; diff --git a/test/sessions/dsh-fixtures.mjs b/test/sessions/dsh-fixtures.mjs index 91bef447..a42b3306 100644 --- a/test/sessions/dsh-fixtures.mjs +++ b/test/sessions/dsh-fixtures.mjs @@ -7,6 +7,10 @@ import * as zlib from "node:zlib"; // packages/core/session/src/{types,invariant,chunk-rows,known-event-types}.ts, // packages/llm/llm/src/{message,types}.ts, and // packages/session/session-persistence-jsonl/src/{format,zstd}.ts. +// RC8 compatibility fixtures additionally mirror deepseek-harness commit +// 141eb6fef83422698aef7a981029e843e8161534: +// packages/core/session/src/types.ts and +// packages/experimental/agent-team/src/{types,fold,task-graph}.ts. export const DSH_FORMAT_VERSION = 0; export const DSH_FIXTURE_SECRET = "sk-dsh_fixture_secret_NEVER_EXPOSE"; @@ -168,6 +172,93 @@ export function makeSupportedDshSessionRows(options = {}) { return fresh([header, ...events]); } +export function makeRc8InterruptedDshSessionRows(options = {}) { + const header = makeDshHeader({ + ...options, + parentSession: Object.hasOwn(options, "parentSession") ? options.parentSession : undefined, + seedLength: Object.hasOwn(options, "seedLength") ? options.seedLength : undefined, + origin: Object.hasOwn(options, "origin") ? options.origin : undefined, + delegationDepth: options.delegationDepth ?? 0, + agentPreset: Object.hasOwn(options, "agentPreset") ? options.agentPreset : undefined, + }); + const time = header.createdAt + 1_000; + return fresh([ + header, + makeDshEvent("turn/start", { turn: 1 }, { seq: 0, time }), + makeDshEvent("step/start", { turn: 1, step: 1 }, { seq: 1, time: time + 1 }), + makeDshEvent("assistant/message", { + turn: 1, + step: 1, + message: assistantMessage("fixture-rc8-interrupted", "Partial synthetic response."), + interrupted: true, + }, { seq: 2, time: time + 2, sourceEventSeqs: [], surfaceOp: "append" }), + makeDshEvent("step/end", { turn: 1, step: 1 }, { seq: 3, time: time + 3 }), + makeDshEvent("turn/end", { + turn: 1, + reason: { kind: "aborted", reason: { kind: "user" } }, + }, { seq: 4, time: time + 4 }), + ]); +} + +export function makeRc8TeamDshSessionRows(options = {}) { + const header = makeDshHeader({ + ...options, + parentSession: Object.hasOwn(options, "parentSession") ? options.parentSession : undefined, + seedLength: Object.hasOwn(options, "seedLength") ? options.seedLength : undefined, + origin: Object.hasOwn(options, "origin") ? options.origin : undefined, + delegationDepth: options.delegationDepth ?? 0, + agentPreset: Object.hasOwn(options, "agentPreset") ? options.agentPreset : undefined, + }); + const time = header.createdAt + 1_000; + const teamId = header.id; + return fresh([ + header, + makeDshEvent("team/member", { + version: 1, + teamId, + member: { + id: "fixture-team-member", + name: "fixture-member", + description: "Synthetic teammate.", + provider: "fixture-provider", + context: "fresh", + phase: "provisioning", + }, + }, { seq: 0, time }), + makeDshEvent("team/task", { + version: 1, + teamId, + task: { + id: "task-1", + revision: 1, + subject: "Inspect synthetic evidence", + description: "Validate the RC8 fixture.", + status: "pending", + blockedBy: [], + writeScopes: [], + }, + }, { seq: 1, time: time + 1 }), + makeDshEvent("team/message/queued", { + version: 1, + teamId, + message: { + id: "fixture-team-message", + senderId: teamId, + senderName: "fixture-lead", + targetId: "fixture-team-member", + delivery: "quiet", + content: [{ type: "text", text: "Synthetic peer message." }], + }, + }, { seq: 2, time: time + 2 }), + makeDshEvent("team/message/delivered", { + version: 1, + teamId, + messageId: "fixture-team-message", + targetId: "fixture-team-member", + }, { seq: 3, time: time + 3 }), + ]); +} + // Mirrors examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl // at the pinned DSH commit while keeping all values synthetic and machine-independent. export function makeNativeSnapshotDshSessionRows(options = {}) { diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index cbb296ae..04889030 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -40,6 +40,8 @@ import { makeNativeSnapshotDshSessionRows, makeOpenTurnDshRows, makePackedDshStorageRows, + makeRc8InterruptedDshSessionRows, + makeRc8TeamDshSessionRows, makeSupportedDshSessionRows, makeTerminalDshSessionRows, makeUnknownIgnorableDshEvent, @@ -127,6 +129,7 @@ const PINNED_KNOWN_EVENT_TYPES = [ "llm/retry-started", "permission/preset", "plan/mode", "request/context", "request/header", "sandbox/mode", "schedule/change", "session/end-seed", "session/title", "session/title-llm-request", "step/end", "step/start", "subagent/descriptor", "todo/write", + "team/member", "team/message/delivered", "team/message/queued", "team/task", "tool-workflow/agent-end", "tool-workflow/agent-start", "tool-workflow/run-end", "tool-workflow/run-start", "tool/call", "tool/code-dispatch", "tool/code-dispatch-start", "tool/result", "turn/end", "turn/start", "user/message", "web/deepseek-search-llm-request", @@ -737,6 +740,70 @@ test("known unsupported and unknown ignorable events are accounted, while open t assert.deepEqual(await readFile(written.filePath), before); }); +test("RC8 interrupted assistant messages accept only the optional literal true marker", () => { + const valid = makeRc8InterruptedDshSessionRows(); + const decoded = decodeDshJsonl(encodeDshRawJsonl(valid)); + assert.equal(decoded.events.find((event) => event.type === "assistant/message").data.interrupted, true); + + for (const value of [false, "true", 1]) { + const rows = makeRc8InterruptedDshSessionRows(); + rows.find((event) => event.type === "assistant/message").data.interrupted = value; + assert.throws(() => decodeDshJsonl(encodeDshRawJsonl(rows)), + stableError("DSH_EVENT_SHAPE_DRIFT")); + } +}); + +test("RC8 team events validate all four strict payloads and remain account-only", () => { + const rows = makeRc8TeamDshSessionRows(); + const decoded = decodeDshJsonl(encodeDshRawJsonl(rows)); + assert.deepEqual(decoded.events.map((event) => event.type), [ + "team/member", "team/task", "team/message/queued", "team/message/delivered", + ]); + assert.deepEqual(decoded.diagnostics.knownUnsupportedTypes, [ + "team/member", "team/message/delivered", "team/message/queued", "team/task", + ]); + assert.equal(decoded.diagnostics.knownUnsupportedCount, 4); + + const malformed = [ + (events) => { events[1].data.member.name = 42; }, + (events) => { events[2].data.task.revision = 0; }, + (events) => { events[3].data.message.delivery = "immediate"; }, + (events) => { delete events[4].data.messageId; }, + (events) => { events[1].data.extra = true; }, + (events) => { events[3].data.message.content[0].extra = true; }, + ]; + for (const mutate of malformed) { + const candidate = makeRc8TeamDshSessionRows(); + mutate(candidate); + assert.throws(() => decodeDshJsonl(encodeDshRawJsonl(candidate)), + stableError("DSH_EVENT_SHAPE_DRIFT")); + } +}); + +test("RC8 team fold enforces member, task, and mailbox relationships", () => { + const invalidCases = [ + (rows) => { rows[1].data.member.phase = "active"; }, + (rows) => { rows[2].data.task.revision = 2; }, + (rows) => { rows[2].data.task.blockedBy = ["task-404"]; }, + (rows) => { rows[4].data.targetId = "different-target"; }, + (rows) => { [rows[3], rows[4]] = [rows[4], rows[3]]; }, + ]; + for (const mutate of invalidCases) { + const rows = makeRc8TeamDshSessionRows(); + mutate(rows); + rows.slice(1).forEach((event, index) => { event.seq = index; }); + assert.throws(() => decodeDshJsonl(encodeDshRawJsonl(rows)), + stableError("DSH_EVENT_RELATIONSHIP_INVALID")); + } + + const foreign = makeRc8TeamDshSessionRows(); + for (const event of foreign.slice(1)) event.data.teamId = "inherited-team-root"; + foreign[1].data.member.phase = "active"; + foreign[2].data.task.revision = 9; + foreign[4].data.targetId = "different-target"; + assert.doesNotThrow(() => decodeDshJsonl(encodeDshRawJsonl(foreign))); +}); + test("pinned types.ts validates todo, request context, and end-seed payloads before accounting", () => { const base = makeSupportedDshSessionRows(); const validEvents = [ diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index 02a34585..29a6db76 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -17,6 +17,8 @@ import { makeDshHeader, makeDshEvent, makeOpenTurnDshRows, + makeRc8InterruptedDshSessionRows, + makeRc8TeamDshSessionRows, makeSupportedDshSessionRows, makeTerminalDshSessionRows, makeUnknownIgnorableDshEvent, @@ -327,11 +329,61 @@ test("DSH assistant usage remains observed only when the native assembled messag reasoningTokens: 1, }); assert.equal(usage.usageFieldsObserved, true); + assert.equal(Object.hasOwn(observed.find((event) => event.type === "assistant"), "interrupted"), false); + assert.equal(Object.hasOwn(usage, "interrupted"), false); assert.equal(unobserved.some((event) => event.type === "model.response.completed"), false); assert.equal(unobserved.some((event) => Object.hasOwn(event, "modelUsage")), false); assert.equal(unobserved.some((event) => Object.hasOwn(event, "usageFieldsObserved")), false); }); +test("DSH preserves RC8 assistant interruption as bounded structural evidence", async () => { + const context = await fixtureContext(); + const rows = makeRc8InterruptedDshSessionRows({ + workspace: context.workspace, + sessionId: "dsh-rc8-interrupted", + }); + rows.find((event) => event.type === "assistant/message").data.usage = { + inputTokens: 5, + outputTokens: 2, + }; + await writeRows(context, rows); + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + assert.equal(sessions.length, 1); + + const events = await analyzer.readSession(sessions[0], scope); + const assistant = events.find((event) => event.type === "assistant"); + assert.equal(assistant.interrupted, true); + assert.equal(assistant.incomplete, true); + assert.equal(Object.hasOwn(assistant, "content"), false); + assert.equal(events.filter((event) => event.type === "assistant").length, 1); + assert.equal(JSON.stringify(events).includes("Partial synthetic response."), false); + const usage = events.find((event) => event.type === "model.response.completed"); + assert.equal(usage.interrupted, true); + assert.equal(usage.incomplete, true); + assert.equal(usage.usageFieldsObserved, true); + + const gated = await analyzer.readSession(sessions[0], scope, { includeContent: true }); + assert.equal(gated.find((event) => event.type === "assistant").content, "Partial synthetic response."); +}); + +test("DSH accounts RC8 team vocabulary without fabricating team analytics or user/tool activity", async () => { + const context = await fixtureContext(); + const rows = makeRc8TeamDshSessionRows({ + workspace: context.workspace, + sessionId: "dsh-rc8-team", + }); + await writeRows(context, rows); + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + assert.equal(sessions.length, 1); + assert.deepEqual(sessions[0].diagnostics.knownUnsupportedTypes, [ + "team/member", "team/message/delivered", "team/message/queued", "team/task", + ]); + assert.equal(sessions[0].diagnostics.knownUnsupportedCount, 4); + assert.deepEqual(await analyzer.readSession(sessions[0], scope), []); +}); + test("DSH tool requests and results correlate while content, arguments, meta, and internal errors stay bounded", async () => { const context = await fixtureContext(); const rows = privacyRows(context, "dsh-tool-correlation"); From 1ea8d17d4f8e687465cb4d26c7f3b8ade22a143a Mon Sep 17 00:00:00 2001 From: "xuannan.chen" Date: Thu, 20 Aug 2026 17:03:28 +0800 Subject: [PATCH 02/13] fix(session-analysis): preserve unknown DSH tool outcomes Only emit normalized success and error fields when pinned DSH records isError. Keep explicit true and false behavior and call/result correlation covered by provider tests. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 10 ++++----- .../session-analysis-dsh-provider.test.mjs | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index 90bcc4e4..2334133c 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -2147,19 +2147,19 @@ function normalizeDshEvents(event, sourceRef, options = {}) { const block = event.data.message.content[0]; const rawText = textFromContent(block.content); const safeText = boundedPrivateText(rawText, 8_000); - const success = block.isError !== true; + const outcomeObserved = Object.hasOwn(block, "isError"); + const success = block.isError === false; const normalized = { ...base, type: "tool.result", category: "tool", lifecyclePhase: "result", toolInvocationId: event.data.message.source.callId, - success, - hasError: !success, + ...(outcomeObserved ? { success, hasError: !success } : {}), ...(event.data.error ? { internalError: true, internalErrorName: safeLabel(event.data.error.name), - internalErrorCode: safeLabel(event.data.error.code) } : { internalError: false }), + internalErrorCode: safeLabel(event.data.error.code) } : outcomeObserved ? { internalError: false } : {}), evidenceRef: normalizedEvidenceRef(sourceRef, event, "tool.result"), - summary: success ? "tool result" : "tool result failed", + summary: block.isError === true ? "tool result failed" : "tool result", }; if (includeGate(options, "includeContent", "include-content") && safeText) normalized.content = safeText; return [normalized]; diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index 29a6db76..be7b9f27 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -384,6 +384,27 @@ test("DSH accounts RC8 team vocabulary without fabricating team analytics or use assert.deepEqual(await analyzer.readSession(sessions[0], scope), []); }); +test("DSH tool result outcomes remain unobserved when native isError is omitted", async () => { + const context = await fixtureContext(); + const rows = makeSupportedDshSessionRows({ + workspace: context.workspace, + sessionId: "dsh-tool-outcome-unobserved", + }); + const nativeResult = rows.find((row) => row.type === "tool/result" && row.data.message.content[0].isError === false); + delete nativeResult.data.message.content[0].isError; + await writeRows(context, rows); + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + const events = await analyzer.readSession(sessions[0], scope); + const call = events.find((event) => event.type === "tool.call"); + const result = events.find((event) => event.type === "tool.result"); + + assert.equal(result.toolInvocationId, call.toolInvocationId); + assert.equal(Object.hasOwn(result, "success"), false); + assert.equal(Object.hasOwn(result, "hasError"), false); + assert.equal(Object.hasOwn(result, "internalError"), false); +}); + test("DSH tool requests and results correlate while content, arguments, meta, and internal errors stay bounded", async () => { const context = await fixtureContext(); const rows = privacyRows(context, "dsh-tool-correlation"); @@ -398,6 +419,7 @@ test("DSH tool requests and results correlate while content, arguments, meta, an const results = events.filter((event) => event.type === "tool.result"); assert.deepEqual(calls.map((event) => event.toolInvocationId), results.map((event) => event.toolInvocationId)); assert.deepEqual(results.map((event) => event.success), [true, false]); + assert.deepEqual(results.map((event) => event.hasError), [false, true]); assert.equal(results[0].internalError, false); assert.equal(results[1].internalError, true); assert.equal(results[1].internalErrorCode, "FIXTURE_TOOL_FAILURE"); From 7b2b7d1dcb7282f139dadd4f1a8504c40c8abc20 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:16:49 +0800 Subject: [PATCH 03/13] fix(dsh): treat blank DSH_HOME as unset Match the pinned rc.8 home resolver by falling back to ~/.dsh for blank or whitespace-only inherited DSH_HOME values while preserving strict explicit-home validation. Implements the AC-1 boundary in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md for #93 and was validated by the focused regression plus the full DSH discovery owner. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 3 ++- test/sessions/session-analysis-dsh-discovery.test.mjs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index 2334133c..4fbcd0c8 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -2196,7 +2196,8 @@ export class DshSessionAnalyzer extends SessionAnalyzer { async resolveScope(options = {}) { const direct = explicitHome(options); const envValue = options.env?.DSH_HOME ?? process.env.DSH_HOME; - const home = expandExplicitHome(direct !== undefined ? direct : envValue !== undefined ? envValue : path.join(os.homedir(), ".dsh")); + const inherited = typeof envValue === "string" && envValue.trim().length === 0 ? undefined : envValue; + const home = expandExplicitHome(direct !== undefined ? direct : inherited !== undefined ? inherited : path.join(os.homedir(), ".dsh")); const workspace = options.workspace ?? process.cwd(); if (!absoluteFlavor(workspace)) fail("DSH_INVALID_WORKSPACE", "workspace must be an absolute path"); const since = normalizeCliDate(options.since); diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index 04889030..9c9ce00a 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -159,6 +159,17 @@ test("DSH scope resolution has strict explicit, environment, and default precede } }); +test("DSH scope treats blank environment homes as unset", async () => { + const analyzer = new DshSessionAnalyzer(); + const workspace = path.resolve("synthetic-workspace"); + const expected = path.join(os.homedir(), ".dsh"); + + for (const value of ["", " ", "\t"]) { + const scope = await analyzer.resolveScope({ env: { DSH_HOME: value }, workspace }); + assert.equal(scope.dshHome, expected); + } +}); + test("production identity encoders independently match the pinned fixture oracle", () => { for (const value of ["session", ".", "..", "with spaces/~and-unicode-\u03bb", "nul\0unit"]) { assert.equal(encodeDshSessionId(value), encodeDshSessionIdSegment(value)); From 6280985dcf05450b44c5876521fb2f7b770ab7fa Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:19:45 +0800 Subject: [PATCH 04/13] fix(dsh): accept JSON-valued ignorable events Allow the pinned rc.8 JSON value domain only for unknown events explicitly marked ignorable, while keeping every known event payload on its strict object schema and omitting unknown payloads from normalized evidence. Implements the AC-7 boundary in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md for #93 and was validated by the focused six-class regression plus the full DSH discovery owner. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 5 ++++- .../session-analysis-dsh-discovery.test.mjs | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index 4fbcd0c8..cb119585 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -1191,9 +1191,12 @@ function validateDeepSeekSearchRequest(data) { } function validateEventEnvelope(event, expectedSeq) { + const unknownIgnorable = plain(event) && typeof event.type === "string" + && event.ignorable === true && !KNOWN_EVENT_TYPES.has(event.type); if (!plain(event) || Object.keys(event).some((key) => !EVENT_KEYS.has(key)) || typeof event.type !== "string" || event.type.length === 0 || event.seq !== expectedSeq - || !safeNonnegative(event.seq) || !validDshEpochMillis(event.time) || !plain(event.data) + || !safeNonnegative(event.seq) || !validDshEpochMillis(event.time) + || !(unknownIgnorable ? isDshJsonValue(event.data) : plain(event.data)) || (Object.hasOwn(event, "ignorable") && event.ignorable !== true)) fail("DSH_INVALID_EVENT"); validateSurface(event); if (!KNOWN_EVENT_TYPES.has(event.type)) { diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index 9c9ce00a..48742a21 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -751,6 +751,27 @@ test("known unsupported and unknown ignorable events are accounted, while open t assert.deepEqual(await readFile(written.filePath), before); }); +test("unknown ignorable events accept every JSON data class without projecting their payloads", async () => { + const root = await tempRoot(); + const home = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + const values = [null, true, 7, "future-string", ["future-array"], { future: "object" }]; + const rows = insertBeforeTurnEnd(makeSupportedDshSessionRows({ workspace, sessionId: "json-ignorable" }), + values.map((data, index) => makeDshEvent(`fixture-future/json-${index}`, data, { ignorable: true }))); + + const decoded = decodeDshJsonl(encodeDshRawJsonl(rows)); + const unknown = decoded.events.filter((event) => event.type.startsWith("fixture-future/json-")); + assert.equal(decoded.diagnostics.unknownIgnorableCount, values.length); + assert.deepEqual(unknown.map((event) => event.data), values); + + await writeNestedDshArtifact({ dshHome: home, rows }); + const { analyzer, scope, sessions } = await inventory(home, workspace); + const normalized = await analyzer.readSession(sessions[0], scope); + const unknownSeqs = new Set(unknown.map((event) => event.seq)); + assert.equal(normalized.some((event) => unknownSeqs.has(event.nativeSeq)), false); + assert.equal(JSON.stringify(normalized).includes("future-string"), false); +}); + test("RC8 interrupted assistant messages accept only the optional literal true marker", () => { const valid = makeRc8InterruptedDshSessionRows(); const decoded = decodeDshJsonl(encodeDshRawJsonl(valid)); From 7a8cc315e41a21a1d38bd74a9c279b6add0a5c61 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:23:53 +0800 Subject: [PATCH 05/13] fix(dsh): exclude inherited seed history from child activity Project only events at or beyond the pinned seedLength boundary after the complete artifact has passed decoding, relationship validation, and surface folding. The all-seed, mixed, and malformed-prefix regression preserves lineage without double-counting parent prompts, usage, tools, or outcomes. Implements the AC-6 provenance boundary in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md for #93 and was validated by the focused provider regression, the full provider owner, and core-facts aggregation tests. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 2 + .../session-analysis-dsh-provider.test.mjs | 82 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index cb119585..b2273c9e 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -2359,6 +2359,7 @@ export class DshSessionAnalyzer extends SessionAnalyzer { fail("DSH_ARTIFACT_IDENTITY_DRIFT"); } const finalSurfaceSeqs = finalDshSurfaceSeqs(artifact.events); + const firstOwnedSeq = artifact.header.seedLength ?? 0; const normalizedSourceRef = { ...ref, sessionId: session.sessionId, @@ -2366,6 +2367,7 @@ export class DshSessionAnalyzer extends SessionAnalyzer { dshProvenance: dshProvenance(artifact.header), }; for (const event of artifact.events) { + if (event.seq < firstOwnedSeq) continue; if (!withinTimeRange(normalizeDshEpochMillis(event.time), scope)) continue; if (!NORMALIZATION_ALLOWLIST.has(event.type)) continue; if (["user/message", "assistant/message", "tool/result"].includes(event.type) diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index be7b9f27..dc4154be 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -98,6 +98,7 @@ function privacyRows(context, sessionId = "dsh-provider-privacy") { workspace: context.workspace, sessionId, parentSession: DSH_FIXTURE_SECRET, + seedLength: undefined, agentPreset: DSH_FIXTURE_SECRET, })); rows[3].data.content[0].text = `Run synthetic validation with ${DSH_FIXTURE_SECRET}`; @@ -128,6 +129,37 @@ function privacyRows(context, sessionId = "dsh-provider-privacy") { return insertProviderRequestHeader(rows); } +function seedOwnershipRows({ workspace, sessionId, includeChild }) { + const rows = makeSupportedDshSessionRows({ + workspace, + sessionId, + parentSession: "fixture-parent", + seedLength: undefined, + origin: "subagent", + delegationDepth: 1, + agentPreset: undefined, + }); + const inherited = rows.slice(1); + rows[0].seedLength = inherited.length; + if (!includeChild) return rows; + + const child = structuredClone(inherited); + for (const event of child) { + event.seq += inherited.length; + event.time += 1_000; + if (Object.hasOwn(event.data, "turn")) event.data.turn = 2; + if (event.type === "user/message") event.data.id = `child-${event.data.id}`; + if (event.type === "assistant/message") event.data.message.id = `child-${event.data.message.id}`; + if (event.type === "tool/call") event.data.callId = `child-${event.data.callId}`; + if (event.type === "tool/result") { + event.data.message.id = `child-${event.data.message.id}`; + event.data.message.source.callId = `child-${event.data.message.source.callId}`; + event.data.message.content[0].toolCallId = `child-${event.data.message.content[0].toolCallId}`; + } + } + return [rows[0], ...inherited, ...child]; +} + test("DSH provider reads the pinned headless snapshot-like base flow without widening normalization", async () => { const context = await fixtureContext("better-harness-dsh-native-snapshot-"); const rows = makeNativeSnapshotDshSessionRows({ @@ -216,6 +248,53 @@ test("DSH provider accounts private subagent descriptors without exposing compos } }); +test("DSH child projection excludes inherited seed activity while preserving lineage and validation", async () => { + const context = await fixtureContext("better-harness-dsh-seed-ownership-"); + const allSeed = seedOwnershipRows({ + workspace: context.workspace, + sessionId: "dsh-seed-only", + includeChild: false, + }); + const mixed = seedOwnershipRows({ + workspace: context.workspace, + sessionId: "dsh-seed-mixed", + includeChild: true, + }); + const malformedSeed = seedOwnershipRows({ + workspace: context.workspace, + sessionId: "dsh-seed-malformed", + includeChild: true, + }); + malformedSeed[3].data.role = "assistant"; + await writeRows(context, allSeed); + await writeRows(context, mixed); + await writeRows(context, malformedSeed); + + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + const byId = new Map(sessions.map((session) => [session.sessionId, session])); + assert.equal(byId.has("dsh-seed-malformed"), false); + assert.equal(analyzer.analysisWarnings.some((warning) => warning.reason === "DSH_EVENT_SHAPE_DRIFT"), true); + + const seedOnlySession = byId.get("dsh-seed-only"); + assert.deepEqual(seedOnlySession.dshProvenance, { + delegationDepth: 1, + parentSession: "fixture-parent", + seedLength: allSeed.length - 1, + origin: "subagent", + }); + assert.deepEqual(await analyzer.readSession(seedOnlySession, scope), []); + + const mixedSession = byId.get("dsh-seed-mixed"); + const events = await analyzer.readSession(mixedSession, scope); + assert.equal(events.every((event) => event.nativeSeq >= mixed[0].seedLength), true); + assert.equal(events.filter((event) => event.userPrompt === true).length, 1); + assert.equal(events.filter((event) => event.type === "tool.call").length, 2); + assert.deepEqual(events.filter((event) => event.type === "tool.result").map((event) => event.success), [true, false]); + assert.equal(events.filter((event) => event.type === "model.response.completed").length, 1); + assert.equal(events.filter((event) => event.type === "turn.end" && event.success === true).length, 1); +}); + test("DSH provider projects only the six approved native event types and publishes dsh-v1 evidence", async () => { const context = await fixtureContext(); const rows = privacyRows(context); @@ -603,7 +682,8 @@ test("DSH provider admits open-step and pending-call crash prefixes without synt const context = await fixtureContext(); const time = Date.parse("2026-08-18T00:00:00.000Z"); const openStepRows = [ - makeDshHeader({ workspace: context.workspace, sessionId: "dsh-open-step", createdAt: time }), + makeDshHeader({ workspace: context.workspace, sessionId: "dsh-open-step", createdAt: time, + parentSession: undefined, seedLength: undefined, origin: undefined, delegationDepth: 0, agentPreset: undefined }), makeDshEvent("turn/start", { turn: 1 }, { seq: 0, time: time + 1 }), makeDshEvent("step/start", { turn: 1, step: 1 }, { seq: 1, time: time + 2 }), ]; From a85cefc368f51f0403c0fe82d113f9b65d24ea51 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:26:40 +0800 Subject: [PATCH 06/13] fix(dsh): contain discovery within sessions root Match pinned rc.8 directory discovery by refusing project and session symlinks, then verify every candidate artifact realpath remains within the canonical configured sessions root before reading it. Escape cases are skipped without exposing external paths. Implements the AC-2 source-boundary contract in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md for #93 and was validated by the focused two-level symlink regression plus the full DSH discovery owner. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 29 +++++++++++----- .../session-analysis-dsh-discovery.test.mjs | 34 ++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index b2273c9e..af642531 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -1900,10 +1900,14 @@ async function directoryEntries(directory) { } } -async function isDirectoryEntry(entry, fullPath) { - if (entry.isDirectory()) return true; - if (!entry.isSymbolicLink()) return false; - try { return (await stat(fullPath)).isDirectory(); } catch { return false; } +function isDirectoryEntry(entry) { + return entry.isDirectory(); +} + +function isContainedPath(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (relative !== ".." + && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); } function sourceRef(candidate) { @@ -2227,16 +2231,24 @@ export class DshSessionAnalyzer extends SessionAnalyzer { if (root) root.warnings = []; return []; } + let canonicalRoot; + try { canonicalRoot = await realpath(root.path); } catch (error) { + if (error?.code === "ENOENT") { + root.warnings = []; + return []; + } + throw error; + } const candidates = []; for (const projectEntry of await directoryEntries(root.path)) { const projectPath = path.join(root.path, projectEntry.name); - if (!(await isDirectoryEntry(projectEntry, projectPath))) { + if (!isDirectoryEntry(projectEntry)) { if (projectEntry.name.startsWith("session.jsonl")) this.analysisWarnings.push(diagnostic("dsh-flat-artifact-rejected")); continue; } for (const sessionEntry of await directoryEntries(projectPath)) { const sessionPath = path.join(projectPath, sessionEntry.name); - if (!(await isDirectoryEntry(sessionEntry, sessionPath))) { + if (!isDirectoryEntry(sessionEntry)) { if (sessionEntry.name.startsWith("session.jsonl")) this.analysisWarnings.push(diagnostic("dsh-flat-artifact-rejected")); continue; } @@ -2246,7 +2258,7 @@ export class DshSessionAnalyzer extends SessionAnalyzer { const nestedArtifacts = []; for (const entry of entries) { const childPath = path.join(sessionPath, entry.name); - if (await isDirectoryEntry(entry, childPath)) { + if (isDirectoryEntry(entry)) { nestedArtifacts.push(...(await directoryEntries(childPath)).filter((child) => child.name.startsWith("session.jsonl"))); } } @@ -2258,7 +2270,8 @@ export class DshSessionAnalyzer extends SessionAnalyzer { const artifact = artifacts[0]; const artifactPath = path.join(sessionPath, artifact.name); let canonicalPath; - try { canonicalPath = await realpath(artifactPath); } catch { canonicalPath = path.resolve(artifactPath); } + try { canonicalPath = await realpath(artifactPath); } catch { continue; } + if (!isContainedPath(canonicalRoot, canonicalPath)) continue; candidates.push({ path: artifactPath, canonicalPath, projectSegment: projectEntry.name, sessionSegment: sessionEntry.name, compressed: artifact.name.endsWith(".zstd") }); } diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index 48742a21..810f869f 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import * as zlib from "node:zlib"; @@ -49,6 +49,8 @@ import { writeNestedDshArtifact, } from "./dsh-fixtures.mjs"; +const SYMLINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + async function tempRoot(prefix = "dsh-discovery-") { return mkdtemp(path.join(os.tmpdir(), prefix)); } @@ -727,6 +729,36 @@ test("flat, wrong-depth, and dual-encoding layouts fail closed without hiding va assert.equal(result.warnings.some((warning) => warning.code === "dsh-ambiguous-artifact-rejected"), true); }); +test("DSH discovery does not admit project or session directory symlinks escaping the sessions root", async () => { + const root = await tempRoot("dsh-containment-"); + for (const level of ["project", "session"]) { + const workspace = path.join(root, `${level}-workspace`); + const home = path.join(root, `${level}-home`); + const outsideHome = path.join(root, `${level}-outside`); + const sessionId = `${level}-escape`; + const written = await writeNestedDshArtifact({ + dshHome: outsideHome, + rows: makeSupportedDshSessionRows({ workspace, sessionId }), + }); + const outsideSession = path.dirname(written.filePath); + const outsideProject = path.dirname(outsideSession); + const sessionsRoot = path.join(home, "sessions"); + await mkdir(sessionsRoot, { recursive: true }); + + if (level === "project") { + await symlink(outsideProject, path.join(sessionsRoot, path.basename(outsideProject)), SYMLINK_TYPE); + } else { + const project = path.join(sessionsRoot, path.basename(outsideProject)); + await mkdir(project, { recursive: true }); + await symlink(outsideSession, path.join(project, path.basename(outsideSession)), SYMLINK_TYPE); + } + + const result = await inventory(home, workspace); + assert.equal(result.sessions.length, 0, level); + assert.equal(JSON.stringify(result).includes(outsideHome), false, level); + } +}); + test("known unsupported and unknown ignorable events are accounted, while open turns are admitted incomplete", async () => { const known = decodeDshJsonl(encodeDshRawJsonl(makeKnownUnsupportedDshSessionRows())); assert.deepEqual(known.diagnostics.knownUnsupportedTypes, ["todo/write"]); From f7653ed221f37d5737eae3a093cf9bd394c2ff97 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:35:45 +0800 Subject: [PATCH 07/13] fix(dsh): recover committed prefixes from crash tails Preserve contiguous raw JSONL rows when an uncommitted suffix is torn or malformed, while rejecting corruption proven committed by a later turn boundary. Recover complete Zstd frames across systematic final-frame truncations and keep complete checksum or structural corruption fail-closed. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/platforms/dsh.mjs | 133 ++++++++++++++---- .../session-analysis-dsh-discovery.test.mjs | 71 ++++++++-- 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/scripts/session-analysis/platforms/dsh.mjs b/scripts/session-analysis/platforms/dsh.mjs index af642531..03cdcd3e 100644 --- a/scripts/session-analysis/platforms/dsh.mjs +++ b/scripts/session-analysis/platforms/dsh.mjs @@ -159,33 +159,35 @@ function readU24(buffer, offset) { return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16); } -/** Split standard checksummed Zstd frames without guessing boundaries from magic bytes. */ -export function splitDshZstdFrames(input) { +function scanDshZstdFrames(input) { const buffer = Buffer.from(input); if (buffer.length === 0) fail("DSH_ZSTD_EMPTY"); const frames = []; let offset = 0; while (offset < buffer.length) { const start = offset; - if (offset + 5 > buffer.length) fail("DSH_ZSTD_TRUNCATED_HEADER"); + if (offset + 4 > buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_HEADER" }; if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) fail("DSH_ZSTD_BAD_MAGIC"); offset += 4; + if (offset === buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_HEADER" }; const descriptor = buffer[offset++]; if ((descriptor & 0x18) !== 0) fail("DSH_ZSTD_RESERVED_DESCRIPTOR"); if ((descriptor & 0x04) === 0) fail("DSH_ZSTD_CHECKSUM_REQUIRED"); const singleSegment = (descriptor & 0x20) !== 0; if (!singleSegment) { - if (offset + 1 > buffer.length) fail("DSH_ZSTD_TRUNCATED_HEADER"); + if (offset + 1 > buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_HEADER" }; offset += 1; } const dictionarySize = [0, 1, 2, 4][descriptor & 0x03]; const fcsFlag = descriptor >>> 6; const contentSizeLength = fcsFlag === 0 ? (singleSegment ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8; - if (offset + dictionarySize + contentSizeLength > buffer.length) fail("DSH_ZSTD_TRUNCATED_HEADER"); + if (offset + dictionarySize + contentSizeLength > buffer.length) { + return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_HEADER" }; + } offset += dictionarySize + contentSizeLength; let lastBlock = false; while (!lastBlock) { - if (offset + 3 > buffer.length) fail("DSH_ZSTD_TRUNCATED_BLOCK"); + if (offset + 3 > buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_BLOCK" }; const blockHeader = readU24(buffer, offset); offset += 3; lastBlock = (blockHeader & 1) === 1; @@ -193,19 +195,28 @@ export function splitDshZstdFrames(input) { const blockSize = blockHeader >>> 3; if (blockType === 3) fail("DSH_ZSTD_RESERVED_BLOCK"); const payloadSize = blockType === 1 ? 1 : blockSize; - if (offset + payloadSize > buffer.length) fail("DSH_ZSTD_TRUNCATED_BLOCK"); + if (offset + payloadSize > buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_BLOCK" }; offset += payloadSize; } - if (offset + 4 > buffer.length) fail("DSH_ZSTD_TRUNCATED_CHECKSUM"); + if (offset + 4 > buffer.length) return { frames, torn: true, tornCode: "DSH_ZSTD_TRUNCATED_CHECKSUM" }; offset += 4; frames.push(Buffer.from(buffer.subarray(start, offset))); } - return frames; + return { frames, torn: false }; +} + +/** Split complete standard checksummed Zstd frames, discarding only a torn final frame. */ +export function splitDshZstdFrames(input) { + const scan = scanDshZstdFrames(input); + if (scan.frames.length === 0 && scan.torn) fail(scan.tornCode); + return scan.frames; } export function decodeDshZstdArtifact(input, { decompressor = zlib.zstdDecompressSync } = {}) { if (typeof decompressor !== "function") fail("DSH_ZSTD_UNAVAILABLE", "DSH compressed evidence is unavailable"); - const frames = splitDshZstdFrames(input); + const scan = scanDshZstdFrames(input); + if (scan.frames.length === 0) fail(scan.torn ? scan.tornCode : "DSH_ZSTD_EMPTY"); + const { frames } = scan; const decoded = []; for (const [index, frame] of frames.entries()) { try { @@ -220,7 +231,7 @@ export function decodeDshZstdArtifact(input, { decompressor = zlib.zstdDecompres fail("DSH_ZSTD_DECOMPRESSION_FAILED"); } } - return { frames, bytes: Buffer.concat(decoded) }; + return { frames, bytes: Buffer.concat(decoded), torn: scan.torn }; } function packedRow(record) { @@ -1757,33 +1768,83 @@ function validateSequence(events, header) { }; } -export function decodeDshJsonl(input) { - let text; +function decodeDshUtf8Line(input) { try { - text = new TextDecoder("utf-8", { fatal: true }).decode(input); + return new TextDecoder("utf-8", { fatal: true }).decode(input); } catch { fail("DSH_INVALID_UTF8"); } - if (text.length === 0) fail("DSH_EMPTY_ARTIFACT"); - if (!text.endsWith("\n")) fail("DSH_INCOMPLETE_JSONL_LINE"); - const lines = text.slice(0, -1).split("\n"); - if (lines.some((line) => line.length === 0)) fail("DSH_BLANK_JSONL_LINE"); - let records; +} + +function parseDshJsonlLine(input) { + const line = decodeDshUtf8Line(input); + if (line.length === 0) fail("DSH_BLANK_JSONL_LINE"); try { - records = lines.map((line) => JSON.parse(line)); + const record = JSON.parse(line); + if (!plain(record)) fail("DSH_MALFORMED_JSONL"); + return record; } catch { fail("DSH_MALFORMED_JSONL"); } - if (records.some((record) => !plain(record))) fail("DSH_MALFORMED_JSONL"); - const header = validateHeader(records[0]); +} + +function expandDshStorageRecord(record) { + const expanded = packedRow(record); + if (expanded) return expanded; + if (typeof record.type === "string" && record.type.endsWith("-chunks")) fail("DSH_UNSUPPORTED_PACKED_ROW"); + return [record]; +} + +function scanDshJsonlPrefix(input, { requireComplete = false } = {}) { + const buffer = Buffer.from(input); + if (buffer.length === 0) fail("DSH_EMPTY_ARTIFACT"); + const headerEnd = buffer.indexOf(0x0A); + if (headerEnd === -1) fail("DSH_INCOMPLETE_JSONL_LINE"); + const header = validateHeader(parseDshJsonlLine(buffer.subarray(0, headerEnd))); const events = []; - for (const record of records.slice(1)) { - const expanded = packedRow(record); - if (expanded) events.push(...expanded); - else if (typeof record.type === "string" && record.type.endsWith("-chunks")) fail("DSH_UNSUPPORTED_PACKED_ROW"); - else events.push(record); + let committedBytes = headerEnd + 1; + let issue = null; + let lineStart = committedBytes; + for (let newline = buffer.indexOf(0x0A, lineStart); newline !== -1; + newline = buffer.indexOf(0x0A, lineStart)) { + let expanded; + try { + expanded = expandDshStorageRecord(parseDshJsonlLine(buffer.subarray(lineStart, newline))); + } catch (error) { + issue ??= error; + lineStart = newline + 1; + continue; + } + if (issue !== null) { + if (expanded.some((event) => event?.type === "turn/end")) throw issue; + lineStart = newline + 1; + continue; + } + const rowStart = events.length; + for (const event of expanded) { + if (event?.seq !== events.length) { + events.length = rowStart; + issue = Object.assign(new Error("DSH_INVALID_EVENT"), { code: "DSH_INVALID_EVENT" }); + break; + } + events.push(event); + } + if (issue !== null && expanded.some((event) => event?.type === "turn/end")) throw issue; + if (issue === null) committedBytes = newline + 1; + lineStart = newline + 1; } + const crashTail = issue !== null || committedBytes < buffer.length; + if (requireComplete && crashTail) throw issue ?? Object.assign( + new Error("DSH_INCOMPLETE_JSONL_LINE"), { code: "DSH_INCOMPLETE_JSONL_LINE" }, + ); + return { header, events, crashTail }; +} + +export function decodeDshJsonl(input, options = {}) { + const { header, events, crashTail } = scanDshJsonlPrefix(input, options); const sequence = validateSequence(events, header); + const incomplete = sequence.incomplete || crashTail; + const incompleteReason = sequence.incompleteReason ?? (crashTail ? "crash-tail" : null); const knownUnsupportedTypes = [...new Set(events.filter((event) => KNOWN_EVENT_TYPES.has(event.type) && !NORMALIZATION_ALLOWLIST.has(event.type) && !CONTROL_TYPES.has(event.type)).map((event) => event.type))].sort(); const unknownIgnorableTypes = [...new Set(events.filter((event) => !KNOWN_EVENT_TYPES.has(event.type) @@ -1791,7 +1852,7 @@ export function decodeDshJsonl(input) { return { header, events, - incomplete: sequence.incomplete, + incomplete, diagnostics: { knownUnsupportedCount: events.filter((event) => KNOWN_EVENT_TYPES.has(event.type) && !NORMALIZATION_ALLOWLIST.has(event.type) && !CONTROL_TYPES.has(event.type)).length, @@ -1799,7 +1860,7 @@ export function decodeDshJsonl(input) { unknownIgnorableCount: events.filter((event) => !KNOWN_EVENT_TYPES.has(event.type) && event.ignorable === true).length, unknownIgnorableTypes, - ...(sequence.incomplete ? { incompleteReason: sequence.incompleteReason } : {}), + ...(incomplete ? { incompleteReason } : {}), ...(sequence.pendingToolCallCount > 0 ? { pendingToolCallCount: sequence.pendingToolCallCount } : {}), ...(sequence.pendingInboxMessageCount > 0 ? { pendingInboxMessageCount: sequence.pendingInboxMessageCount } : {}), }, @@ -1807,8 +1868,18 @@ export function decodeDshJsonl(input) { } export function decodeDshArtifact(input, { compressed = false, decompressor = zlib.zstdDecompressSync } = {}) { - const decoded = compressed ? decodeDshZstdArtifact(input, { decompressor }).bytes : Buffer.from(input); - return decodeDshJsonl(decoded); + if (!compressed) return decodeDshJsonl(Buffer.from(input)); + const decoded = decodeDshZstdArtifact(input, { decompressor }); + const result = decodeDshJsonl(decoded.bytes, { requireComplete: true }); + if (!decoded.torn) return result; + return { + ...result, + incomplete: true, + diagnostics: { + ...result.diagnostics, + incompleteReason: result.diagnostics.incompleteReason ?? "crash-tail", + }, + }; } function pathMatchesWorkspace(cwd, workspace) { diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index 810f869f..4940c5cc 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -285,7 +285,7 @@ test("missing public decompressor marks compressed unavailable while independent test("Zstd frame scanner rejects malformed boundaries with stable privacy-safe codes", () => { const header = Buffer.from([0x28, 0xB5, 0x2F, 0xFD, 0x24, 0x00]); const cases = [ - [Buffer.from([0x50, 0x2A, 0x4D, 0x18]), "DSH_ZSTD_TRUNCATED_HEADER"], + [Buffer.from([0x50, 0x2A, 0x4D, 0x18]), "DSH_ZSTD_BAD_MAGIC"], [Buffer.from([0x00, 0x00, 0x00, 0x00, 0x24]), "DSH_ZSTD_BAD_MAGIC"], [Buffer.from([0x28, 0xB5, 0x2F, 0xFD, 0x2C, 0x00]), "DSH_ZSTD_RESERVED_DESCRIPTOR"], [Buffer.from([0x28, 0xB5, 0x2F, 0xFD, 0x20, 0x00]), "DSH_ZSTD_CHECKSUM_REQUIRED"], @@ -298,8 +298,56 @@ test("Zstd frame scanner rejects malformed boundaries with stable privacy-safe c const syntacticallyComplete = Buffer.concat([header, Buffer.from([0x01, 0, 0]), Buffer.alloc(4)]); assert.throws(() => decodeDshZstdArtifact(syntacticallyComplete, { decompressor() { throw new Error(DSH_FIXTURE_SECRET); } }), stableError("DSH_ZSTD_DECOMPRESSION_FAILED")); - assert.throws(() => splitDshZstdFrames(Buffer.concat([syntacticallyComplete, Buffer.from([1])])), - stableError("DSH_ZSTD_TRUNCATED_HEADER")); + assert.deepEqual(splitDshZstdFrames(Buffer.concat([syntacticallyComplete, Buffer.from([1])])), + [syntacticallyComplete]); +}); + +test("crash tails preserve only the committed raw and Zstd event prefix", () => { + const rows = makeSupportedDshSessionRows({ sessionId: "crash-tail-prefix" }); + const committedRows = rows.slice(0, -1); + const committedEvents = committedRows.slice(1); + const finalRow = Buffer.from(JSON.stringify(rows.at(-1)), "utf8"); + + const partialRaw = decodeDshJsonl(Buffer.concat([ + encodeDshRawJsonl(committedRows), + finalRow.subarray(0, Math.max(1, Math.floor(finalRow.length / 2))), + ])); + assert.deepEqual(partialRaw.events, committedEvents); + assert.equal(partialRaw.incomplete, true); + assert.equal(partialRaw.diagnostics.incompleteReason, "open-turn"); + + const malformedLogicalRow = { + ...makeDshEvent("step/start", { turn: 1, step: 2 }), + seq: committedEvents.length + 1, + }; + const malformedTail = decodeDshJsonl(encodeDshRawJsonl([...committedRows, malformedLogicalRow])); + assert.deepEqual(malformedTail.events, committedEvents); + assert.equal(malformedTail.incomplete, true); + assert.equal(malformedTail.diagnostics.incompleteReason, "open-turn"); + assert.throws(() => decodeDshJsonl(Buffer.concat([ + encodeDshRawJsonl([...committedRows, malformedLogicalRow]), + Buffer.from(`${JSON.stringify(rows.at(-1))}\n`, "utf8"), + ])), stableError("DSH_INVALID_EVENT")); + + if (typeof zlib.zstdCompressSync !== "function" || typeof zlib.zstdDecompressSync !== "function" + || !Number.isSafeInteger(zlib.constants?.ZSTD_c_checksumFlag)) return; + const fixture = makeDshZstdArtifact([[rows[0]], rows.slice(1, -1), [rows.at(-1)]]); + const committedArtifact = Buffer.concat(fixture.frames.slice(0, -1)); + const finalFrame = fixture.frames.at(-1); + for (let length = 1; length < finalFrame.length; length += 1) { + const decoded = decodeDshArtifact(Buffer.concat([committedArtifact, finalFrame.subarray(0, length)]), { + compressed: true, + }); + assert.deepEqual(decoded.events, committedEvents, `proper final-frame prefix length ${length}`); + assert.equal(decoded.incomplete, true, `proper final-frame prefix length ${length}`); + assert.equal(decoded.diagnostics.incompleteReason, "open-turn", `proper final-frame prefix length ${length}`); + } + assert.throws(() => decodeDshArtifact(Buffer.concat([committedArtifact, Buffer.alloc(4)]), { compressed: true }), + stableError("DSH_ZSTD_BAD_MAGIC")); + const checksumCorrupt = Buffer.from(finalFrame); + checksumCorrupt[checksumCorrupt.length - 1] ^= 0xFF; + assert.throws(() => decodeDshArtifact(Buffer.concat([committedArtifact, checksumCorrupt]), { compressed: true }), + stableError("DSH_ZSTD_DECOMPRESSION_FAILED")); }); test("all three pinned packed rows expand losslessly and malformed packed shapes fail closed", () => { @@ -321,7 +369,8 @@ test("all three pinned packed rows expand losslessly and malformed packed shapes assert.deepEqual(decoded.diagnostics.knownUnsupportedTypes, ["assistant/chunk"]); assert.deepEqual(decoded.events.map((event) => event.seq), decoded.events.map((_, index) => index)); for (const row of makeMalformedPackedDshStorageRows()) { - assert.throws(() => decodeDshJsonl(encodeDshRawJsonl([header, row])), + const committedBoundary = makeDshEvent("turn/end", { turn: 1, reason: { kind: "completed" } }, { seq: 0 }); + assert.throws(() => decodeDshJsonl(encodeDshRawJsonl([header, row, committedBoundary])), (error) => ["DSH_MALFORMED_PACKED_ROW", "DSH_UNSUPPORTED_PACKED_ROW"].includes(error?.code)); } }); @@ -686,11 +735,17 @@ test("header, format, sequence, identity, project key, raw syntax, and unknown-r [makeBadSequenceDshRows(), "DSH_INVALID_EVENT"], ]; for (const [rows, code] of directCases) assert.throws(() => decodeDshJsonl(encodeDshRawJsonl(rows)), stableError(code)); - assert.throws(() => decodeDshJsonl(makeMalformedDshJsonlBytes()), stableError("DSH_MALFORMED_JSONL")); + const malformedTail = decodeDshJsonl(makeMalformedDshJsonlBytes()); + assert.equal(malformedTail.events.length, 0); + assert.equal(malformedTail.incomplete, true); + assert.equal(malformedTail.diagnostics.incompleteReason, "crash-tail"); const valid = encodeDshRawJsonl(makeSupportedDshSessionRows()); - assert.throws(() => decodeDshJsonl(valid.subarray(0, -1)), stableError("DSH_INCOMPLETE_JSONL_LINE")); - assert.throws(() => decodeDshJsonl(Buffer.concat([valid.subarray(0, valid.length - 1), Buffer.from("\n\n")])), - stableError("DSH_BLANK_JSONL_LINE")); + const withoutFinalNewline = decodeDshJsonl(valid.subarray(0, -1)); + assert.deepEqual(withoutFinalNewline.events, decodeDshJsonl(encodeDshRawJsonl(makeSupportedDshSessionRows().slice(0, -1))).events); + assert.equal(withoutFinalNewline.incomplete, true); + const blankTail = decodeDshJsonl(Buffer.concat([valid.subarray(0, valid.length - 1), Buffer.from("\n\n")])); + assert.equal(blankTail.incomplete, true); + assert.equal(blankTail.diagnostics.incompleteReason, "crash-tail"); const unknownRows = appendEvent(makeSupportedDshSessionRows(), makeUnknownRequiredDshEvent({ seq: 10 })); assert.throws(() => decodeDshJsonl(encodeDshRawJsonl(unknownRows)), stableError("DSH_UNKNOWN_REQUIRED_EVENT")); From 09b7ad29ce23bb5db73deb21ed483e1389876399 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:39:02 +0800 Subject: [PATCH 08/13] fix(session-analysis): retain repeated tool invocation occurrences Partition lifecycle groups when a reused stable invocation id opens again after its prior post/result boundary. This preserves sequential occurrences through shared deduplication and tool tracing without changing provider-native identifiers. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/episode-contract.mjs | 15 +++-- .../session-analysis-dsh-provider.test.mjs | 60 +++++++++++++++++++ .../session-episode-contract.test.mjs | 17 ++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/scripts/session-analysis/episode-contract.mjs b/scripts/session-analysis/episode-contract.mjs index 18739401..94ff3706 100644 --- a/scripts/session-analysis/episode-contract.mjs +++ b/scripts/session-analysis/episode-contract.mjs @@ -172,7 +172,8 @@ export function canonicalTaskKey(event) { } export function deduplicateLifecycleEvents(events = []) { - const groups = new Map(); + const groups = []; + const activeGroups = new Map(); const ungrouped = []; for (const event of deduplicatePromptSubmissionEvents(events)) { @@ -183,13 +184,19 @@ export function deduplicateLifecycleEvents(events = []) { continue; } const key = `${event.sessionId ?? "unknown"}:${invocation}`; - const group = groups.get(key) ?? []; + let group = activeGroups.get(key); + if (group?.some((candidate) => ["post", "result"].includes(candidate.lifecyclePhase)) + && ["pre", "request"].includes(phase)) group = undefined; + if (!group) { + group = []; + groups.push(group); + activeGroups.set(key, group); + } group.push(event); - groups.set(key, group); } const merged = []; - for (const group of groups.values()) { + for (const group of groups) { group.sort(compareEvents); const canonical = group.findLast((event) => event.lifecyclePhase === "result") ?? group.findLast((event) => event.lifecyclePhase === "post") diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index dc4154be..4fffe3de 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -9,7 +9,9 @@ import { DSH_ADAPTER_VERSION, DshSessionAnalyzer, } from "../../scripts/session-analysis/platforms/dsh.mjs"; +import { deduplicateLifecycleEvents } from "../../scripts/session-analysis/episode-contract.mjs"; import { runProviderCommand } from "../../scripts/session-analysis/provider-runner.mjs"; +import { buildToolCallTrace } from "../../scripts/session-analysis/tool-call-trace.mjs"; import { DSH_FIXTURE_SECRET, dshProjectKey, @@ -823,6 +825,49 @@ function reusedCallAcrossTurnsRows(workspace) { return rows; } +function reusedCallAcrossStepsRows(workspace) { + const source = makeSupportedDshSessionRows({ workspace }); + const header = makeDshHeader({ + workspace, + sessionId: "dsh-provider-reused-call-steps", + parentSession: undefined, + seedLength: undefined, + origin: undefined, + delegationDepth: 0, + agentPreset: undefined, + }); + const firstCall = structuredClone(source.find((row) => row.type === "tool/call")); + const firstResult = structuredClone(source.find((row) => row.type === "tool/result")); + const eventTime = header.createdAt + 1_000; + firstCall.seq = 2; + firstCall.time = eventTime + 20; + firstResult.seq = 3; + firstResult.time = eventTime + 30; + const secondCall = structuredClone(firstCall); + secondCall.seq = 6; + secondCall.time = eventTime + 60; + secondCall.data.step = 2; + const secondResult = structuredClone(firstResult); + secondResult.seq = 7; + secondResult.time = eventTime + 70; + secondResult.data.step = 2; + secondResult.data.message.id = "fixture-tool-reused-step-result"; + const rows = [ + header, + makeDshEvent("turn/start", { turn: 1 }, { seq: 0, time: eventTime }), + makeDshEvent("step/start", { turn: 1, step: 1 }, { seq: 1, time: eventTime + 10 }), + firstCall, + firstResult, + makeDshEvent("step/end", { turn: 1, step: 1 }, { seq: 4, time: eventTime + 40 }), + makeDshEvent("step/start", { turn: 1, step: 2 }, { seq: 5, time: eventTime + 50 }), + secondCall, + secondResult, + makeDshEvent("step/end", { turn: 1, step: 2 }, { seq: 8, time: eventTime + 80 }), + makeDshEvent("turn/end", { turn: 1, reason: { kind: "completed" } }, { seq: 9, time: eventTime + 90 }), + ]; + return rows; +} + test("DSH normalization projects final canonical surface nodes and preserves callId reuse across turns", async () => { const context = await fixtureContext(); await writeRows(context, surfaceReplacementRows(context.workspace)); @@ -852,6 +897,21 @@ test("DSH normalization projects final canonical surface nodes and preserves cal && event.toolInvocationId === "fixture-call-success").length, 2); }); +test("DSH repeated callId occurrences survive lifecycle deduplication and tool tracing", async () => { + const context = await fixtureContext(); + await writeRows(context, reusedCallAcrossStepsRows(context.workspace)); + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + const session = sessions.find((candidate) => candidate.sessionId === "dsh-provider-reused-call-steps"); + const events = await analyzer.readSession(session, scope); + const canonical = deduplicateLifecycleEvents(events); + const occurrences = canonical.filter((event) => event.category === "tool" + && event.toolInvocationId === "fixture-call-success"); + assert.equal(occurrences.length, 2); + assert.deepEqual(occurrences.map((event) => event.step), [1, 2]); + assert.equal(buildToolCallTrace(events).totalCalls, 2); +}); + test("DSH provider reads concatenated Zstd evidence and reports unavailable compression without hiding raw sessions", async () => { const context = await fixtureContext(); const rawRows = makeSupportedDshSessionRows({ workspace: context.workspace, sessionId: "dsh-provider-raw" }); diff --git a/test/sessions/session-episode-contract.test.mjs b/test/sessions/session-episode-contract.test.mjs index b4d3a935..ad4b2a39 100644 --- a/test/sessions/session-episode-contract.test.mjs +++ b/test/sessions/session-episode-contract.test.mjs @@ -170,6 +170,23 @@ test("lifecycle pre/post records deduplicate only with a stable invocation id", assert.equal(events[0].permissionDecision, "allowed"); }); +test("lifecycle deduplication retains sequential occurrences that reuse an invocation id", () => { + const events = deduplicateLifecycleEvents([ + event({ timestamp: "2026-07-10T10:00:00.000Z", toolName: "Read", toolInvocationId: "reused", + lifecyclePhase: "request" }), + event({ timestamp: "2026-07-10T10:00:01.000Z", toolName: "Read", toolInvocationId: "reused", + lifecyclePhase: "result", success: true }), + event({ timestamp: "2026-07-10T10:00:02.000Z", toolName: "Read", toolInvocationId: "reused", + lifecyclePhase: "request" }), + event({ timestamp: "2026-07-10T10:00:03.000Z", toolName: "Read", toolInvocationId: "reused", + lifecyclePhase: "result", success: false }), + ]); + + assert.equal(events.length, 2); + assert.deepEqual(events.map((item) => item.success), [true, false]); + assert.ok(events.every((item) => item.lifecycle.deduplicated)); +}); + test("permission observations keep routine allows aggregate and bound real boundary evidence", () => { const { episodes, permissionSummary } = buildTaskEpisodes([ event({ timestamp: "2026-07-10T10:00:00.000Z", toolName: "Bash", permissionDecision: "allowed", permissionMode: "unknown", line: 1 }), From 1b6f93898d1b33242589de7a6083ef52563c1d42 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 22:41:12 +0800 Subject: [PATCH 09/13] docs(dsh): record audited rc8 evidence boundaries Align the Issue #93 specification and adapter matrices with the audited RC7/RC8 format-0 slice, including the independently tested persistence, discovery, ownership, outcome, and lifecycle contracts. Keep all unavailable host and persistence capabilities explicit. Co-authored-by: Codex (GPT 5.6 Sol) --- docs/adapters/README.md | 5 +- docs/docs/hosts/adapter-matrix.md | 8 ++- ...18-93-deepseek-harness-session-evidence.md | 72 ++++++++++++------- 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 71e959d4..d523a4e7 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -206,8 +206,9 @@ edit host settings, or register an `apply` path. projected as team analytics. Known-but-unsupported events and unknown ignorable events are explicitly accounted for. Unknown required events, malformed records, identity drift, - and unsupported versions fail closed; an open trailing turn remains - incomplete. Bounded source distinctions are retained without copying + committed corruption, and unsupported versions fail closed; an uncommitted + raw row or incomplete final Zstandard frame preserves only the prior + committed prefix and remains incomplete. Bounded source distinctions are retained without copying arbitrary plugin data or inferring plugin ownership, causality, or faults. Compressed artifacts are concatenated independently checksummed Zstandard frames and are scanned and decompressed one complete frame at a time. The diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 5f524e63..95f4ddf4 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -138,9 +138,11 @@ feature-detected. When it is unavailable, including Node.js 23.0 through 23.7, compressed evidence is reported unavailable while independent raw JSONL evidence remains readable; no fallback dependency is installed. Known-but-unsupported and unknown ignorable events are accounted -for, while unknown required events, malformed data, identity drift, and -unsupported versions fail closed. Open trailing turns remain incomplete, and -the adapter does not infer plugin ownership, causality, or faults. +for, while unknown required events, committed corruption, identity drift, and +unsupported versions fail closed. Uncommitted final raw rows and structurally +incomplete final Zstandard frames preserve only the prior committed prefix and +remain incomplete. The adapter does not infer plugin ownership, causality, or +faults. The implemented source-checkout smoke boundary is read-only: diff --git a/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md b/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md index d7624329..bb0c256e 100644 --- a/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md +++ b/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md @@ -17,16 +17,18 @@ not present DSH as a first-class or natively integrated Better Harness host. This specification freezes the partial boundary approved by the maintainer in [Issue #93](https://github.com/QoderAI/better-harness/issues/93), including the feature-detection policy approved in the 2026-08-18T13:13:05Z comment. The -supported native contract is pinned to upstream commit -`99f6f02fecdb7dff40c3fbc9470f5907c29f74ca`, tag/contract -`dsh-v0.1.0-rc.7`, and `SESSION_FORMAT_VERSION = 0`. Later upstream behavior is -not implicitly supported. +supported native contract is pinned to upstream commits +`99f6f02fecdb7dff40c3fbc9470f5907c29f74ca` (`dsh-v0.1.0-rc.7`) and +`141eb6fef83422698aef7a981029e843e8161534` (`dsh-v0.1.0-rc.8`), both with +`SESSION_FORMAT_VERSION = 0`. Later upstream behavior is not implicitly +supported. ## Native Contract Evidence -The implementation and its support claims must remain bound to these five -primary upstream sources at commit -`99f6f02fecdb7dff40c3fbc9470f5907c29f74ca`: +The implementation and its support claims remain bound to these five primary +upstream sources at the RC7 commit, plus the corresponding session types, +JSONL persistence scanner, home-path resolution, and experimental team event +contracts at the RC8 commit: 1. [Developer-preview status, compatibility warning, and plugin-oriented positioning](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/README.md) 2. [Base profile composition and the DSH-home sessions route](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/packages/bundle/base/cordis.patch.yml) @@ -68,9 +70,10 @@ a broader host capability. ### AC-1: Scope resolution An explicit `--dsh-home` value takes precedence over inherited `DSH_HOME`, -which takes precedence over the default `~/.dsh`. The only session root is -`/sessions`; empty, malformed, or otherwise unresolved values do -not trigger guesses at alternate roots. +which takes precedence over the default `~/.dsh`. A blank or whitespace-only +inherited `DSH_HOME` is unset; explicit values retain normal path validation. +The only session root is `/sessions`; malformed or otherwise +unresolved values do not trigger guesses at alternate roots. ### AC-2: Artifact discovery @@ -78,7 +81,9 @@ Discovery accepts only the fixed nested DSH JSONL layout containing `session.jsonl.zstd` or raw `session.jsonl`. It deduplicates by canonical artifact path and bound session identity. Conflicting encodings or identities, flat legacy layouts, and ambiguous artifacts fail closed and contribute no -session evidence. +session evidence. Discovery does not follow project/session directory symlinks, +and every artifact's canonical path must remain contained by the canonical +sessions root. ### AC-3: Physical and logical validation @@ -91,13 +96,20 @@ pinned upstream shape and their logical events participate in sequence validation; a packed storage row is never treated as a `SessionEvent` itself. Malformed records, identity or sequence mismatch, unsupported versions, and same-version structural drift are rejected. +An uncommitted final raw row without a newline, or a malformed/sequence-broken +suffix not followed by a committed `turn/end`, is excluded while the contiguous +committed prefix remains available and explicitly incomplete. The same defect +before a later `turn/end` is committed corruption and rejects. ### AC-4: Zstandard runtime policy A compressed artifact is treated as a concatenation of independently checksummed Zstandard frames. The adapter scans and validates frame boundaries, decompresses each complete frame independently, and concatenates the decoded -payloads; it must not submit the entire file to a single decompression call. +payloads; it must not submit the entire file to a single decompression call. A +structurally incomplete final frame is an uncommitted crash tail and is excluded +while prior complete frames remain available; invalid complete structure or a +checksum-corrupt complete frame rejects. The public Zstandard API available in supported Node.js 22.20 and 24 runtimes is feature-detected at runtime. Where Node.js 23.0 through 23.7 exposes no required public API, compressed evidence is explicitly unavailable while raw JSONL @@ -120,6 +132,10 @@ source distinctions, native call/result ids, turn/step/sequence coordinates, and bounded `parentSession`, `seedLength`, `origin`, `delegationDepth`, and `agentPreset` provenance. It does not copy arbitrary raw or plugin data and does not infer plugin ownership, plugin causality, or faulty-plugin attribution. +RC8 interrupted assistant and team events are validated/accounted without +inventing team analytics. Inherited rows before `seedLength` validate as part +of the artifact but do not become child-owned activity. A native call id reused +after its prior lifecycle closes remains a distinct invocation occurrence. ### AC-7: Privacy and completeness @@ -130,6 +146,9 @@ unobserved rather than becoming zero. An unknown required event rejects the artifact; an unknown ignorable event is explicitly accounted for. An open trailing turn is marked incomplete. Every source read is read-only, and no path repairs or rewrites an upstream artifact. +Unknown ignorable event data may be any JSON value. An omitted native +`tool/result.isError` remains an unobserved outcome; only explicit true/false or +independent native error evidence establishes the corresponding outcome facts. ### AC-8: Capability boundary @@ -144,11 +163,14 @@ through to another adapter. Synthetic fixtures and behavioral tests cover raw JSONL, concatenated checksummed Zstandard frames, workspace acceptance and rejection, packed rows, -event correlation, every terminal outcome, unknown required and ignorable -events, bad format version/header/id/sequence, malformed data, an open trailing -turn, canonical path and session-identity deduplication, privacy gates, -Zstandard API absence, and Windows/macOS/Linux path behavior. No fixture contains -a real transcript, secret, credential, or machine-specific absolute path. +event correlation, every terminal outcome, RC8 interruption/team vocabulary, +unknown required and JSON-valued ignorable events, unknown tool outcomes, +inherited seed ownership, reused call ids, bad format version/header/id/sequence, +committed corruption and uncommitted crash tails, canonical path and +session-identity deduplication, sessions-root containment, privacy gates, +Zstandard API absence, and Windows/macOS/Linux path behavior. No fixture +contains a real transcript, secret, credential, or machine-specific absolute +path. ### AC-10: Honest documentation @@ -179,7 +201,7 @@ diff contains no real transcript, secret, credential, or machine absolute path. - Global Node.js engine-range changes or new dependencies. - Automatic harness optimization or self-modification. - Faulty-plugin identification, plugin ownership, or causality inference. -- Upstream artifact mutation, repair, or recovery. +- Upstream artifact mutation or repair. - Complete first-class DeepSeek Harness support. ## Plan and Tasks @@ -218,13 +240,13 @@ native sources; widening support requires a separately approved specification. | Acceptance criteria | Test or command | Expected review evidence | | --- | --- | --- | -| AC-1 | Focused home-resolution tests | CLI override wins over environment and default; the only derived root ends in `sessions`; invalid values do not guess. | -| AC-2 | Focused discovery and dedupe tests | Only fixed nested raw/compressed artifacts qualify; canonical path/session collisions, flat legacy, conflicts, and ambiguity fail closed. | -| AC-3 | Header, identity, sequence, malformed, and packed-row fixture tests | Format-0 headers and logical records validate; packed logical events retain contiguous sequence; drift and malformed input are rejected. | -| AC-4 | Raw, concatenated checksummed-frame, truncated-frame, and API-absence tests | Each complete frame is decoded independently; truncation rejects; unavailable API disables only compressed evidence; dependency and engine diffs stay empty. | +| AC-1 | Focused home-resolution tests | CLI override wins over environment and default; blank inherited environment is unset; the only derived root ends in `sessions`. | +| AC-2 | Focused discovery, containment, and dedupe tests | Only fixed nested raw/compressed artifacts qualify; canonical path/session collisions, symlink escapes, flat legacy, conflicts, and ambiguity fail closed. | +| AC-3 | Header, identity, sequence, packed-row, and raw crash-tail fixture tests | Format-0 records validate; committed prefixes survive uncommitted tails; drift proven committed by a later boundary rejects. | +| AC-4 | Concatenated checksummed-frame, systematic torn-tail, checksum-corruption, and API-absence tests | Complete frames decode independently; an incomplete final frame is excluded; complete corruption rejects; unavailable API disables only compressed evidence. | | AC-5 | Workspace topology tests on Windows/macOS/Linux path forms | Absolute header `cwd` follows existing case/canonical/symlink semantics and foreign workspaces never contribute facts. | -| AC-6 | Event normalization, correlation, and provenance tests | Only allowlisted evidence appears; native ids and bounded coordinates/lineage survive; arbitrary/plugin fields and causal claims do not. | -| AC-7 | Privacy-gate, unknown-event, missing-usage, open-turn, and read-only tests | Content gates redact credential-shaped values; absence stays unobserved; required unknowns reject, ignorable unknowns are accounted, and open turns stay incomplete without writes. | +| AC-6 | Event normalization, correlation, seed-ownership, RC8 vocabulary, and lifecycle tests | Only allowlisted owned evidence appears; interrupted/team rows validate; reused ids retain occurrences; arbitrary/plugin fields and causal claims do not. | +| AC-7 | Privacy-gate, JSON-valued ignorable, unknown-outcome, missing-usage, incomplete, and read-only tests | Content gates redact credential-shaped values; absence stays unobserved; required unknowns reject; crash/open tails stay incomplete without writes. | | AC-8 | Catalog capability mapping, loader, CLI help, and unknown-host tests | `dsh-v1` accepts only format 0; only `sessionAnalysis` maps to `dsh`; loader/CLI route explicitly and unknown hosts reject. | | AC-9 | Focused cross-platform synthetic fixture suite | Every enumerated encoding, validation, lifecycle, identity, privacy, runtime, and path case has deterministic behavioral evidence with no real local data. | | AC-10 | Adapter matrix assertions plus `npx vitest run test/skills-docs/doc-link-graph.test.mjs` | Both matrices expose the same partial JSONL claim and unavailable slices; links resolve; README Quickstart/Installation remains unchanged. | @@ -242,7 +264,7 @@ not described as a successful compressed-session smoke. | --- | --- | | Same-version structural drift | Validate the complete supported header and logical shapes against pinned fixtures; reject unrecognized required structure. | | Packed rows mistaken for events | Decode only the upstream lossless packed-row shape before sequence validation; reject malformed packing and never normalize the storage row itself. | -| Multi-frame Zstandard truncation or silent tail loss | Scan and validate every boundary/checksum, require complete frames, decompress frame by frame, and reject a truncated or trailing-invalid artifact. | +| Multi-frame Zstandard truncation or silent tail loss | Scan every boundary, exclude only a structurally incomplete final frame, retain prior complete frames, and reject complete structural/checksum corruption. | | Zstandard API unavailable | Feature-detect the public API, mark compressed evidence unavailable, and continue to support independent raw evidence. | | Path alias, case, or symlink mismatch | Reuse existing workspace topology and canonical path semantics with cross-platform positive and negative fixtures. | | Foreign workspace admitted | Require the absolute header `cwd` to qualify; do not consult the lossy directory name or another heuristic. | From 74229c27bf3193c0c3223dd1597368d14aa77e5f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 23:49:24 +0800 Subject: [PATCH 10/13] test(dsh): strengthen compatibility edge coverage Extend the #93 regressions in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md with falsy JSON values and a seed boundary that splits a valid tool lifecycle. The focused discovery and provider owners passed without production changes. Co-authored-by: Codex (GPT 5.6 Sol) --- .../session-analysis-dsh-discovery.test.mjs | 2 +- .../session-analysis-dsh-provider.test.mjs | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/test/sessions/session-analysis-dsh-discovery.test.mjs b/test/sessions/session-analysis-dsh-discovery.test.mjs index 4940c5cc..4ef215e2 100644 --- a/test/sessions/session-analysis-dsh-discovery.test.mjs +++ b/test/sessions/session-analysis-dsh-discovery.test.mjs @@ -842,7 +842,7 @@ test("unknown ignorable events accept every JSON data class without projecting t const root = await tempRoot(); const home = path.join(root, "home"); const workspace = path.join(root, "workspace"); - const values = [null, true, 7, "future-string", ["future-array"], { future: "object" }]; + const values = [null, false, true, 0, 7, "future-string", ["future-array"], { future: "object" }]; const rows = insertBeforeTurnEnd(makeSupportedDshSessionRows({ workspace, sessionId: "json-ignorable" }), values.map((data, index) => makeDshEvent(`fixture-future/json-${index}`, data, { ignorable: true }))); diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index 4fffe3de..421260e9 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -162,6 +162,30 @@ function seedOwnershipRows({ workspace, sessionId, includeChild }) { return [rows[0], ...inherited, ...child]; } +function crossSeedToolLifecycleRows(workspace) { + const rows = makeSupportedDshSessionRows({ + workspace, + sessionId: "dsh-cross-seed-tool", + parentSession: "fixture-parent", + seedLength: 6, + origin: "subagent", + delegationDepth: 1, + agentPreset: undefined, + }); + const call = rows.find((event) => event.type === "tool/call" + && event.data.callId === "fixture-call-success"); + const result = rows.find((event) => event.type === "tool/result" + && event.data.message.source.callId === "fixture-call-success"); + call.data.callId = "cross-seed-call"; + result.data.message.source.callId = "cross-seed-call"; + result.data.message.content[0].toolCallId = "cross-seed-call"; + const filtered = rows.filter((event) => !(event.type === "tool/call" + && event.data.callId === "fixture-call-error") && !(event.type === "tool/result" + && event.data.message.source.callId === "fixture-call-error")); + filtered.slice(1).forEach((event, index) => { event.seq = index; }); + return filtered; +} + test("DSH provider reads the pinned headless snapshot-like base flow without widening normalization", async () => { const context = await fixtureContext("better-harness-dsh-native-snapshot-"); const rows = makeNativeSnapshotDshSessionRows({ @@ -297,6 +321,26 @@ test("DSH child projection excludes inherited seed activity while preserving lin assert.equal(events.filter((event) => event.type === "turn.end" && event.success === true).length, 1); }); +test("DSH seed ownership validates inherited calls before projecting child-owned results", async () => { + const context = await fixtureContext("better-harness-dsh-cross-seed-tool-"); + const rows = crossSeedToolLifecycleRows(context.workspace); + await writeRows(context, rows); + + const analyzer = new DshSessionAnalyzer(); + const { scope, sessions } = await discover(analyzer, context); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].incomplete, false); + assert.equal(Object.hasOwn(sessions[0].diagnostics, "incompleteReason"), false); + assert.deepEqual(analyzer.analysisWarnings, []); + + const events = await analyzer.readSession(sessions[0], scope); + assert.equal(events.every((event) => event.nativeSeq >= rows[0].seedLength), true); + assert.equal(events.filter((event) => event.type === "tool.call").length, 0); + const results = events.filter((event) => event.type === "tool.result"); + assert.equal(results.length, 1); + assert.equal(results[0].toolInvocationId, "cross-seed-call"); +}); + test("DSH provider projects only the six approved native event types and publishes dsh-v1 evidence", async () => { const context = await fixtureContext(); const rows = privacyRows(context); From 103c21c894e05679ae17eda097f9ca3c4574f957 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 20 Aug 2026 23:53:24 +0800 Subject: [PATCH 11/13] docs(dsh): point rc8 evidence to rc8 sources Keep the historical RC7 links for #93 while binding RC8-only and requalified contracts to their exact owners at 141eb6fef83422698aef7a981029e843e8161534 in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md. All seven fixed URLs returned 200 and the documentation link graph passed. Co-authored-by: Codex (GPT 5.6 Sol) --- ...-08-18-93-deepseek-harness-session-evidence.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md b/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md index bb0c256e..592b5dfe 100644 --- a/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md +++ b/docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md @@ -26,9 +26,7 @@ supported. ## Native Contract Evidence The implementation and its support claims remain bound to these five primary -upstream sources at the RC7 commit, plus the corresponding session types, -JSONL persistence scanner, home-path resolution, and experimental team event -contracts at the RC8 commit: +upstream sources at the RC7 commit: 1. [Developer-preview status, compatibility warning, and plugin-oriented positioning](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/README.md) 2. [Base profile composition and the DSH-home sessions route](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/packages/bundle/base/cordis.patch.yml) @@ -36,6 +34,17 @@ contracts at the RC8 commit: 4. [JSONL layout, default Zstandard encoding, packed rows, identity checks, and discovery constraints](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/packages/session/session-persistence-jsonl/README.md) 5. [SQLite's separate persistence and discovery contract](https://github.com/deepseek-ai/deepseek-harness/blob/99f6f02fecdb7dff40c3fbc9470f5907c29f74ca/packages/session/session-persistence-sqlite/README.md) +The RC8 requalification and RC8-only extensions are separately bound to their +corresponding source owners at the RC8 commit: + +6. [`assistant/message.interrupted` and the current session event vocabulary](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/core/session/src/types.ts) +7. [Committed JSONL rows and packed-row expansion](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/session/session-persistence-jsonl/src/format.ts) +8. [Concatenated Zstandard frame scanning and torn-frame boundaries](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/session/session-persistence-jsonl/src/zstd.ts) +9. [DSH-home precedence and blank-environment handling](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/util/home-paths/src/index.ts) +10. [`team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` payload contracts](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/experimental/agent-team/src/types.ts) +11. [Strict team payload schemas and replay relationships](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/experimental/agent-team/src/fold.ts) +12. [Team task dependency-graph constraints](https://github.com/deepseek-ai/deepseek-harness/blob/141eb6fef83422698aef7a981029e843e8161534/packages/experimental/agent-team/src/task-graph.ts) + Synthetic fixtures may encode only behavior supported by those pinned sources and the approved Issue #93 boundary. A fixture passing is not evidence that a newer DSH build remains compatible. Same-version structural drift must fail From f730a7d44c2e89b70274f1c7e1a1d9f71e056107 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 00:55:44 +0800 Subject: [PATCH 12/13] fix(secret-scan): preserve filesystem identity precision Windows file identities can exceed JavaScript safe integer precision. Use full-width filesystem stats for the existing before/open identity comparison so distinct files cannot compare equal through Number precision loss. When inode identity is unavailable, retain the conservative metadata fallback with exact nanosecond timestamps. This hardens the existing fail-closed swap check; it does not claim to eliminate all filesystem TOCTOU races. Validated with the Node 22.20 secret-scan and agent suites, the bounded full repository suite, generated checks, workspace tests, and package verification. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/agent-guardrails/secret-scan.mjs | 20 +-- .../agent-guardrails-secret-scan.test.mjs | 148 ++++++++++++++++++ 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/scripts/agent-guardrails/secret-scan.mjs b/scripts/agent-guardrails/secret-scan.mjs index a20d77cf..5b72d299 100644 --- a/scripts/agent-guardrails/secret-scan.mjs +++ b/scripts/agent-guardrails/secret-scan.mjs @@ -675,7 +675,7 @@ async function readScannableFile(file, opts, stats) { let canonical; let handle; try { - beforeOpen = await fs.lstat(file); + beforeOpen = await fs.lstat(file, { bigint: true }); if (beforeOpen.isSymbolicLink() || !beforeOpen.isFile()) { recordCoverageGap(stats, file, "scan target changed type before it could be read safely"); return null; @@ -686,7 +686,7 @@ async function readScannableFile(file, opts, stats) { return null; } handle = await openReadOnlyNoFollow(file); - const opened = await handle.stat(); + const opened = await handle.stat({ bigint: true }); if (!opened.isFile() || !sameFileIdentity(beforeOpen, opened)) { recordCoverageGap(stats, file, "scan target changed while it was being opened"); return null; @@ -723,17 +723,17 @@ function isWithinRoot(root, target) { } function sameFileIdentity(beforeOpen, opened) { - const beforeIno = Number(beforeOpen?.ino ?? 0); - const openedIno = Number(opened?.ino ?? 0); - const beforeDev = Number(beforeOpen?.dev ?? 0); - const openedDev = Number(opened?.dev ?? 0); - if (beforeDev !== 0 && openedDev !== 0 && beforeDev !== openedDev) return false; - if (beforeIno !== 0 || openedIno !== 0) { + const beforeIno = beforeOpen?.ino ?? 0n; + const openedIno = opened?.ino ?? 0n; + const beforeDev = beforeOpen?.dev ?? 0n; + const openedDev = opened?.dev ?? 0n; + if (beforeDev !== 0n && openedDev !== 0n && beforeDev !== openedDev) return false; + if (beforeIno !== 0n || openedIno !== 0n) { return beforeIno === openedIno; } return beforeOpen?.size === opened?.size - && beforeOpen?.mtimeMs === opened?.mtimeMs - && beforeOpen?.birthtimeMs === opened?.birthtimeMs; + && beforeOpen?.mtimeNs === opened?.mtimeNs + && beforeOpen?.birthtimeNs === opened?.birthtimeNs; } function makeFinding({ rule, file, secret, line, column, lineText, entropy, cwd }) { diff --git a/test/agents/agent-guardrails-secret-scan.test.mjs b/test/agents/agent-guardrails-secret-scan.test.mjs index 9938823b..c25279ab 100644 --- a/test/agents/agent-guardrails-secret-scan.test.mjs +++ b/test/agents/agent-guardrails-secret-scan.test.mjs @@ -60,6 +60,69 @@ function syntheticOpenAiKey() { ].join("-"); } +function withFileIdentity(stats, identity) { + return new Proxy(stats, { + get(target, property) { + if (Object.hasOwn(identity, property)) return identity[property]; + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function identityForStats(identity, options) { + if (options?.bigint === true) return identity; + return Object.fromEntries( + Object.entries(identity).map(([key, value]) => [key, Number(value)]), + ); +} + +async function scanWithFileIdentities(beforeIdentity, openedIdentity) { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-secret-scan-identity-")); + const workspace = path.join(root, "workspace"); + const configPath = path.join(workspace, "config.yaml"); + await mkdir(workspace, { recursive: true }); + await writeFile(configPath, "name: safe\n"); + + const originalLstat = fsPromises.lstat; + const originalOpen = fsPromises.open; + fsPromises.lstat = async (target, ...args) => { + const stats = await originalLstat.call(fsPromises, target, ...args); + return path.resolve(String(target)) === configPath + ? withFileIdentity(stats, identityForStats(beforeIdentity, args[0])) + : stats; + }; + fsPromises.open = async (target, ...args) => { + const handle = await originalOpen.call(fsPromises, target, ...args); + if (path.resolve(String(target)) !== configPath) return handle; + return new Proxy(handle, { + get(targetHandle, property) { + if (property === "stat") { + return async (...statArgs) => withFileIdentity( + await targetHandle.stat(...statArgs), + identityForStats(openedIdentity, statArgs[0]), + ); + } + const value = Reflect.get(targetHandle, property, targetHandle); + return typeof value === "function" ? value.bind(targetHandle) : value; + }, + }); + }; + + try { + return await scanPaths(["config.yaml"], { + cwd: workspace, + containmentRoot: workspace, + failOn: "high", + redact: true, + }); + } finally { + fsPromises.lstat = originalLstat; + fsPromises.open = originalOpen; + await rm(root, { recursive: true, force: true }); + } +} + test("secret guard platform registry owns host-specific install paths", () => { assert.deepEqual(supportedSecretGuardPlatforms(), ["codex", "qoder"]); assert.deepEqual(validateSecretGuardPlatforms(), []); @@ -200,6 +263,91 @@ test("workspace-contained scanning refuses a swap to an external symlink at read assert.doesNotMatch(JSON.stringify(report.findings), new RegExp(outsideKey)); }); +test("workspace-contained scanning rejects distinct full-width identities that collide as Numbers", async () => { + const firstInode = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const secondInode = firstInode + 1n; + assert.notEqual(firstInode, secondInode); + assert.equal(Number(firstInode), Number(secondInode)); + + const report = await scanWithFileIdentities( + { dev: 7n, ino: firstInode }, + { dev: 7n, ino: secondInode }, + ); + + assert.equal(report.stats.scannedFiles, 0); + assert.equal(report.coverageStatus, "failed"); + assert.ok(report.stats.errors.length > 0); +}); + +test("workspace-contained scanning accepts an identical full-width identity", async () => { + const inode = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const report = await scanWithFileIdentities( + { dev: 7n, ino: inode }, + { dev: 7n, ino: inode }, + ); + + assert.equal(report.stats.scannedFiles, 1); + assert.equal(report.coverageStatus, "complete"); + assert.equal(report.stats.errors.length, 0); +}); + +test("workspace-contained scanning rejects ordinary device and inode mismatches", async () => { + const deviceMismatch = await scanWithFileIdentities( + { dev: 7n, ino: 101n }, + { dev: 8n, ino: 101n }, + ); + const inodeMismatch = await scanWithFileIdentities( + { dev: 7n, ino: 101n }, + { dev: 7n, ino: 102n }, + ); + + for (const report of [deviceMismatch, inodeMismatch]) { + assert.equal(report.stats.scannedFiles, 0); + assert.equal(report.coverageStatus, "failed"); + assert.ok(report.stats.errors.length > 0); + } +}); + +test("workspace-contained scanning preserves sub-millisecond fallback precision when inode is unavailable", async () => { + const before = { + dev: 0n, + ino: 0n, + size: 11n, + mtimeMs: 1_000n, + birthtimeMs: 500n, + mtimeNs: 1_000_000_001n, + birthtimeNs: 500_000_000n, + }; + const opened = { + ...before, + mtimeNs: before.mtimeNs + 1n, + }; + + const report = await scanWithFileIdentities(before, opened); + + assert.equal(report.stats.scannedFiles, 0); + assert.equal(report.coverageStatus, "failed"); + assert.ok(report.stats.errors.length > 0); +}); + +test("workspace-contained scanning accepts identical metadata when inode is unavailable", async () => { + const identity = { + dev: 0n, + ino: 0n, + size: 11n, + mtimeMs: 1_000n, + birthtimeMs: 500n, + mtimeNs: 1_000_000_001n, + birthtimeNs: 500_000_000n, + }; + + const report = await scanWithFileIdentities(identity, identity); + + assert.equal(report.stats.scannedFiles, 1); + assert.equal(report.coverageStatus, "complete"); + assert.equal(report.stats.errors.length, 0); +}); + test("UserPromptSubmit hook blocks secrets without echoing the value", async () => { const fakeKey = syntheticOpenAiKey(); const result = await handleHookEvent({ From 53534b2fb38f9bfe15985cd020293286da079ba3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 01:33:38 +0800 Subject: [PATCH 13/13] fix(session-analysis): preserve lifecycle order across timestamp drift Repeated native invocation IDs are legal after a prior lifecycle closes. Lifecycle deduplication previously sorted by timestamp before occurrence partitioning, so non-monotonic timestamps could reorder two valid occurrences and collapse them into one. Partition lifecycle occurrences using stable provider/native order while preserving timestamps for duration evidence and final presentation. Implements #93 AC-6 in docs/specs/2026-08-18-93-deepseek-harness-session-evidence.md and was validated by focused lifecycle and DSH suites, all session suites, npm test -- --maxWorkers=4, and package verification. Co-authored-by: Codex (GPT 5.6 Sol) --- scripts/session-analysis/episode-contract.mjs | 6 +- .../session-analysis-dsh-provider.test.mjs | 14 ++-- .../session-episode-contract.test.mjs | 70 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/scripts/session-analysis/episode-contract.mjs b/scripts/session-analysis/episode-contract.mjs index 94ff3706..da48851e 100644 --- a/scripts/session-analysis/episode-contract.mjs +++ b/scripts/session-analysis/episode-contract.mjs @@ -176,7 +176,9 @@ export function deduplicateLifecycleEvents(events = []) { const activeGroups = new Map(); const ungrouped = []; - for (const event of deduplicatePromptSubmissionEvents(events)) { + // Provider order carries lifecycle semantics; wall-clock observations may + // drift and are reserved for duration evidence and final presentation. + for (const event of events) { const invocation = event?.toolInvocationId ?? event?.requestId ?? event?.callId ?? null; const phase = event?.lifecyclePhase ?? null; if (!invocation || !phase || !["pre", "request", "post", "result"].includes(phase)) { @@ -226,7 +228,7 @@ export function deduplicateLifecycleEvents(events = []) { }); } - return [...ungrouped, ...merged].sort(compareEvents); + return [...deduplicatePromptSubmissionEvents(ungrouped), ...merged].sort(compareEvents); } function deduplicatePromptSubmissionEvents(events) { diff --git a/test/sessions/session-analysis-dsh-provider.test.mjs b/test/sessions/session-analysis-dsh-provider.test.mjs index 421260e9..51fa3ee1 100644 --- a/test/sessions/session-analysis-dsh-provider.test.mjs +++ b/test/sessions/session-analysis-dsh-provider.test.mjs @@ -886,14 +886,14 @@ function reusedCallAcrossStepsRows(workspace) { firstCall.seq = 2; firstCall.time = eventTime + 20; firstResult.seq = 3; - firstResult.time = eventTime + 30; + firstResult.time = eventTime + 40; const secondCall = structuredClone(firstCall); secondCall.seq = 6; - secondCall.time = eventTime + 60; + secondCall.time = eventTime + 30; secondCall.data.step = 2; const secondResult = structuredClone(firstResult); secondResult.seq = 7; - secondResult.time = eventTime + 70; + secondResult.time = eventTime + 50; secondResult.data.step = 2; secondResult.data.message.id = "fixture-tool-reused-step-result"; const rows = [ @@ -941,16 +941,22 @@ test("DSH normalization projects final canonical surface nodes and preserves cal && event.toolInvocationId === "fixture-call-success").length, 2); }); -test("DSH repeated callId occurrences survive lifecycle deduplication and tool tracing", async () => { +test("DSH repeated callId occurrences survive timestamp drift through lifecycle deduplication and tool tracing", async () => { const context = await fixtureContext(); await writeRows(context, reusedCallAcrossStepsRows(context.workspace)); const analyzer = new DshSessionAnalyzer(); const { scope, sessions } = await discover(analyzer, context); const session = sessions.find((candidate) => candidate.sessionId === "dsh-provider-reused-call-steps"); const events = await analyzer.readSession(session, scope); + const calls = events.filter((event) => event.type === "tool.call"); + const results = events.filter((event) => event.type === "tool.result"); const canonical = deduplicateLifecycleEvents(events); const occurrences = canonical.filter((event) => event.category === "tool" && event.toolInvocationId === "fixture-call-success"); + assert.equal(calls.length, 2); + assert.equal(results.length, 2); + assert.deepEqual(calls.map((event) => [event.step, event.toolInvocationId]), + results.map((event) => [event.step, event.toolInvocationId])); assert.equal(occurrences.length, 2); assert.deepEqual(occurrences.map((event) => event.step), [1, 2]); assert.equal(buildToolCallTrace(events).totalCalls, 2); diff --git a/test/sessions/session-episode-contract.test.mjs b/test/sessions/session-episode-contract.test.mjs index ad4b2a39..2ba3f483 100644 --- a/test/sessions/session-episode-contract.test.mjs +++ b/test/sessions/session-episode-contract.test.mjs @@ -9,6 +9,7 @@ import { } from "../../scripts/session-analysis/episode-contract.mjs"; import { buildInsightPack } from "../../scripts/session-analysis/insights.mjs"; import { buildObservationManifest } from "../../scripts/session-analysis/observation-manifest.mjs"; +import { buildToolCallTrace } from "../../scripts/session-analysis/tool-call-trace.mjs"; function event({ sessionId = "session-a", @@ -187,6 +188,75 @@ test("lifecycle deduplication retains sequential occurrences that reuse an invoc assert.ok(events.every((item) => item.lifecycle.deduplicated)); }); +test("lifecycle deduplication retains reused invocation occurrences across timestamp drift", () => { + const input = [ + event({ timestamp: "2026-07-10T10:00:00.000Z", toolName: "Read A", toolInvocationId: "reused", + lifecyclePhase: "request" }), + event({ timestamp: "2026-07-10T10:00:03.000Z", toolName: "Read A", toolInvocationId: "reused", + lifecyclePhase: "result", success: true }), + event({ timestamp: "2026-07-10T10:00:01.000Z", toolName: "Read B", toolInvocationId: "reused", + lifecyclePhase: "request" }), + event({ timestamp: "2026-07-10T10:00:02.000Z", toolName: "Read B", toolInvocationId: "reused", + lifecyclePhase: "result", success: false }), + ]; + + const occurrences = deduplicateLifecycleEvents(input); + + assert.equal(occurrences.length, 2); + assert.deepEqual(occurrences.map((item) => [item.toolName, item.success]), [ + ["Read B", false], + ["Read A", true], + ]); + assert.equal(buildToolCallTrace(input).totalCalls, 2); +}); + +test("lifecycle occurrence boundaries preserve duplicate and incomplete telemetry controls", () => { + const occurrenceCount = (phases, timestamps) => deduplicateLifecycleEvents(phases.map((lifecyclePhase, index) => + event({ + timestamp: timestamps[index], + toolName: "Read", + toolInvocationId: "reused", + lifecyclePhase, + success: lifecyclePhase === "result", + }))).length; + const equalTimestampEvents = ["request", "result", "request", "result"].map((lifecyclePhase) => event({ + timestamp: "2026-07-10T10:00:00.000Z", + toolName: "Read", + toolInvocationId: "reused", + lifecyclePhase, + success: lifecyclePhase === "result", + })); + + assert.deepEqual({ + duplicateRequestTelemetry: occurrenceCount( + ["request", "request", "result"], + ["2026-07-10T10:00:00.000Z", "2026-07-10T10:00:01.000Z", "2026-07-10T10:00:02.000Z"], + ), + incompletePriorOccurrence: occurrenceCount( + ["request", "request", "result"], + ["2026-07-10T10:00:02.000Z", "2026-07-10T10:00:00.000Z", "2026-07-10T10:00:01.000Z"], + ), + equalTimestamps: deduplicateLifecycleEvents(equalTimestampEvents).length, + equalTimestampIdsPreserved: deduplicateLifecycleEvents(equalTimestampEvents) + .every((item) => item.toolInvocationId === "reused"), + threeCompletedOccurrences: occurrenceCount( + ["request", "result", "request", "result", "request", "result"], + Array.from({ length: 6 }, (_, index) => `2026-07-10T10:00:0${index}.000Z`), + ), + duplicateSecondRequest: occurrenceCount( + ["request", "result", "request", "request", "result"], + Array.from({ length: 5 }, (_, index) => `2026-07-10T10:00:0${index}.000Z`), + ), + }, { + duplicateRequestTelemetry: 1, + incompletePriorOccurrence: 1, + equalTimestamps: 2, + equalTimestampIdsPreserved: true, + threeCompletedOccurrences: 3, + duplicateSecondRequest: 2, + }); +}); + test("permission observations keep routine allows aggregate and bound real boundary evidence", () => { const { episodes, permissionSummary } = buildTaskEpisodes([ event({ timestamp: "2026-07-10T10:00:00.000Z", toolName: "Bash", permissionDecision: "allowed", permissionMode: "unknown", line: 1 }),