From 612a029475365c716d386c3ad2c64c4654441b53 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Fri, 26 Jun 2026 14:22:59 +0200 Subject: [PATCH 1/2] agent-trace: Capture question tool answers in conversation trace Map completed OpenCode question tool parts into text message.part payloads so selected answers are persisted through the existing conversation-trace path. Update generated plugin copies and runtime contract documentation to match the new capture behavior. Co-authored-by: SCE --- .opencode/plugins/sce-agent-trace.ts | 86 +++++++++++++++++++ config/.opencode/plugins/sce-agent-trace.ts | 86 +++++++++++++++++++ .../.opencode/plugins/sce-agent-trace.ts | 86 +++++++++++++++++++ .../opencode-sce-agent-trace-plugin.ts | 86 +++++++++++++++++++ context/context-map.md | 2 +- context/glossary.md | 2 +- .../opencode-agent-trace-plugin-runtime.md | 27 +++++- 7 files changed, 371 insertions(+), 4 deletions(-) diff --git a/.opencode/plugins/sce-agent-trace.ts b/.opencode/plugins/sce-agent-trace.ts index 1c4d09d4..a4932024 100644 --- a/.opencode/plugins/sce-agent-trace.ts +++ b/.opencode/plugins/sce-agent-trace.ts @@ -48,6 +48,13 @@ type ConversationTracePayload = { payloads: ConversationTraceItem[]; }; +type QuestionToolAnswer = { + question: string; + answer: string; +}; + +const QUESTION_TOOL_ANSWER_SEPARATOR = ", "; + type EventMessageUpdated = Extract< NonNullable, { type: "message.updated" } @@ -58,6 +65,9 @@ type EventMessagePartUpdated = Extract< { type: "message.part.updated" } >; +type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; +type EventMessageToolPart = Extract; + function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], ) { @@ -105,6 +115,45 @@ function shouldCaptureEvent(eventType: OpenCodeEvent["type"]): boolean { return ALL_CAPTURED_EVENTS.has(eventType); } +function extractQuestionToolAnswers( + eventPart: EventMessageToolPart, +): QuestionToolAnswer[] | undefined { + const state = eventPart.state; + + if (state.status !== "completed") { + return undefined; + } + + const questions = + "questions" in state.input && Array.isArray(state.input.questions) + ? state.input.questions + : []; + const answers = + "answers" in state.metadata && Array.isArray(state.metadata.answers) + ? state.metadata.answers + : []; + + if (questions.length === 0 || questions.length !== answers.length) { + return undefined; + } + + const result: QuestionToolAnswer[] = []; + + questions.forEach((q, index) => { + const question = + "question" in q && typeof q.question === "string" ? q.question : ""; + if (question) { + const answer = Array.isArray(answers[index]) ? answers[index] : []; + result.push({ + question, + answer: answer.join(QUESTION_TOOL_ANSWER_SEPARATOR), + }); + } + }); + + return result; +} + function buildConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload { @@ -142,6 +191,29 @@ export function buildMessagePartConversationTracePayload( }; } +function buildQuestionToolConversationTracePayload( + eventPart: EventMessageToolPart, +): ConversationTracePayload | undefined { + const pairedAnswers = extractQuestionToolAnswers(eventPart); + + if (pairedAnswers === undefined) { + return undefined; + } + + return { + payloads: [ + { + type: "message.part", + session_id: eventPart.sessionID, + message_id: eventPart.messageID, + part_type: "text", + text: JSON.stringify(pairedAnswers), + generated_at_unix_ms: Date.now(), + }, + ], + }; +} + function buildPatchConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload | undefined { @@ -183,6 +255,20 @@ export async function recordConversationTrace( repoRoot: string, event: EventMessageUpdated | EventMessagePartUpdated, ): Promise { + if ( + event.type === "message.part.updated" && + event.properties.part.type === "tool" && + event.properties.part.tool === "question" + ) { + const questionToolPayload = buildQuestionToolConversationTracePayload( + event.properties.part, + ); + if (questionToolPayload !== undefined) { + await runConversationTraceHook(repoRoot, questionToolPayload); + return; + } + } + if ( event.type === "message.part.updated" && (event.properties.part.type === "reasoning" || diff --git a/config/.opencode/plugins/sce-agent-trace.ts b/config/.opencode/plugins/sce-agent-trace.ts index 1c4d09d4..a4932024 100644 --- a/config/.opencode/plugins/sce-agent-trace.ts +++ b/config/.opencode/plugins/sce-agent-trace.ts @@ -48,6 +48,13 @@ type ConversationTracePayload = { payloads: ConversationTraceItem[]; }; +type QuestionToolAnswer = { + question: string; + answer: string; +}; + +const QUESTION_TOOL_ANSWER_SEPARATOR = ", "; + type EventMessageUpdated = Extract< NonNullable, { type: "message.updated" } @@ -58,6 +65,9 @@ type EventMessagePartUpdated = Extract< { type: "message.part.updated" } >; +type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; +type EventMessageToolPart = Extract; + function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], ) { @@ -105,6 +115,45 @@ function shouldCaptureEvent(eventType: OpenCodeEvent["type"]): boolean { return ALL_CAPTURED_EVENTS.has(eventType); } +function extractQuestionToolAnswers( + eventPart: EventMessageToolPart, +): QuestionToolAnswer[] | undefined { + const state = eventPart.state; + + if (state.status !== "completed") { + return undefined; + } + + const questions = + "questions" in state.input && Array.isArray(state.input.questions) + ? state.input.questions + : []; + const answers = + "answers" in state.metadata && Array.isArray(state.metadata.answers) + ? state.metadata.answers + : []; + + if (questions.length === 0 || questions.length !== answers.length) { + return undefined; + } + + const result: QuestionToolAnswer[] = []; + + questions.forEach((q, index) => { + const question = + "question" in q && typeof q.question === "string" ? q.question : ""; + if (question) { + const answer = Array.isArray(answers[index]) ? answers[index] : []; + result.push({ + question, + answer: answer.join(QUESTION_TOOL_ANSWER_SEPARATOR), + }); + } + }); + + return result; +} + function buildConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload { @@ -142,6 +191,29 @@ export function buildMessagePartConversationTracePayload( }; } +function buildQuestionToolConversationTracePayload( + eventPart: EventMessageToolPart, +): ConversationTracePayload | undefined { + const pairedAnswers = extractQuestionToolAnswers(eventPart); + + if (pairedAnswers === undefined) { + return undefined; + } + + return { + payloads: [ + { + type: "message.part", + session_id: eventPart.sessionID, + message_id: eventPart.messageID, + part_type: "text", + text: JSON.stringify(pairedAnswers), + generated_at_unix_ms: Date.now(), + }, + ], + }; +} + function buildPatchConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload | undefined { @@ -183,6 +255,20 @@ export async function recordConversationTrace( repoRoot: string, event: EventMessageUpdated | EventMessagePartUpdated, ): Promise { + if ( + event.type === "message.part.updated" && + event.properties.part.type === "tool" && + event.properties.part.tool === "question" + ) { + const questionToolPayload = buildQuestionToolConversationTracePayload( + event.properties.part, + ); + if (questionToolPayload !== undefined) { + await runConversationTraceHook(repoRoot, questionToolPayload); + return; + } + } + if ( event.type === "message.part.updated" && (event.properties.part.type === "reasoning" || diff --git a/config/automated/.opencode/plugins/sce-agent-trace.ts b/config/automated/.opencode/plugins/sce-agent-trace.ts index 1c4d09d4..a4932024 100644 --- a/config/automated/.opencode/plugins/sce-agent-trace.ts +++ b/config/automated/.opencode/plugins/sce-agent-trace.ts @@ -48,6 +48,13 @@ type ConversationTracePayload = { payloads: ConversationTraceItem[]; }; +type QuestionToolAnswer = { + question: string; + answer: string; +}; + +const QUESTION_TOOL_ANSWER_SEPARATOR = ", "; + type EventMessageUpdated = Extract< NonNullable, { type: "message.updated" } @@ -58,6 +65,9 @@ type EventMessagePartUpdated = Extract< { type: "message.part.updated" } >; +type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; +type EventMessageToolPart = Extract; + function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], ) { @@ -105,6 +115,45 @@ function shouldCaptureEvent(eventType: OpenCodeEvent["type"]): boolean { return ALL_CAPTURED_EVENTS.has(eventType); } +function extractQuestionToolAnswers( + eventPart: EventMessageToolPart, +): QuestionToolAnswer[] | undefined { + const state = eventPart.state; + + if (state.status !== "completed") { + return undefined; + } + + const questions = + "questions" in state.input && Array.isArray(state.input.questions) + ? state.input.questions + : []; + const answers = + "answers" in state.metadata && Array.isArray(state.metadata.answers) + ? state.metadata.answers + : []; + + if (questions.length === 0 || questions.length !== answers.length) { + return undefined; + } + + const result: QuestionToolAnswer[] = []; + + questions.forEach((q, index) => { + const question = + "question" in q && typeof q.question === "string" ? q.question : ""; + if (question) { + const answer = Array.isArray(answers[index]) ? answers[index] : []; + result.push({ + question, + answer: answer.join(QUESTION_TOOL_ANSWER_SEPARATOR), + }); + } + }); + + return result; +} + function buildConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload { @@ -142,6 +191,29 @@ export function buildMessagePartConversationTracePayload( }; } +function buildQuestionToolConversationTracePayload( + eventPart: EventMessageToolPart, +): ConversationTracePayload | undefined { + const pairedAnswers = extractQuestionToolAnswers(eventPart); + + if (pairedAnswers === undefined) { + return undefined; + } + + return { + payloads: [ + { + type: "message.part", + session_id: eventPart.sessionID, + message_id: eventPart.messageID, + part_type: "text", + text: JSON.stringify(pairedAnswers), + generated_at_unix_ms: Date.now(), + }, + ], + }; +} + function buildPatchConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload | undefined { @@ -183,6 +255,20 @@ export async function recordConversationTrace( repoRoot: string, event: EventMessageUpdated | EventMessagePartUpdated, ): Promise { + if ( + event.type === "message.part.updated" && + event.properties.part.type === "tool" && + event.properties.part.tool === "question" + ) { + const questionToolPayload = buildQuestionToolConversationTracePayload( + event.properties.part, + ); + if (questionToolPayload !== undefined) { + await runConversationTraceHook(repoRoot, questionToolPayload); + return; + } + } + if ( event.type === "message.part.updated" && (event.properties.part.type === "reasoning" || diff --git a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts index 1c4d09d4..a4932024 100644 --- a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts +++ b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts @@ -48,6 +48,13 @@ type ConversationTracePayload = { payloads: ConversationTraceItem[]; }; +type QuestionToolAnswer = { + question: string; + answer: string; +}; + +const QUESTION_TOOL_ANSWER_SEPARATOR = ", "; + type EventMessageUpdated = Extract< NonNullable, { type: "message.updated" } @@ -58,6 +65,9 @@ type EventMessagePartUpdated = Extract< { type: "message.part.updated" } >; +type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; +type EventMessageToolPart = Extract; + function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], ) { @@ -105,6 +115,45 @@ function shouldCaptureEvent(eventType: OpenCodeEvent["type"]): boolean { return ALL_CAPTURED_EVENTS.has(eventType); } +function extractQuestionToolAnswers( + eventPart: EventMessageToolPart, +): QuestionToolAnswer[] | undefined { + const state = eventPart.state; + + if (state.status !== "completed") { + return undefined; + } + + const questions = + "questions" in state.input && Array.isArray(state.input.questions) + ? state.input.questions + : []; + const answers = + "answers" in state.metadata && Array.isArray(state.metadata.answers) + ? state.metadata.answers + : []; + + if (questions.length === 0 || questions.length !== answers.length) { + return undefined; + } + + const result: QuestionToolAnswer[] = []; + + questions.forEach((q, index) => { + const question = + "question" in q && typeof q.question === "string" ? q.question : ""; + if (question) { + const answer = Array.isArray(answers[index]) ? answers[index] : []; + result.push({ + question, + answer: answer.join(QUESTION_TOOL_ANSWER_SEPARATOR), + }); + } + }); + + return result; +} + function buildConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload { @@ -142,6 +191,29 @@ export function buildMessagePartConversationTracePayload( }; } +function buildQuestionToolConversationTracePayload( + eventPart: EventMessageToolPart, +): ConversationTracePayload | undefined { + const pairedAnswers = extractQuestionToolAnswers(eventPart); + + if (pairedAnswers === undefined) { + return undefined; + } + + return { + payloads: [ + { + type: "message.part", + session_id: eventPart.sessionID, + message_id: eventPart.messageID, + part_type: "text", + text: JSON.stringify(pairedAnswers), + generated_at_unix_ms: Date.now(), + }, + ], + }; +} + function buildPatchConversationTracePayload( event: EventMessageUpdated, ): ConversationTracePayload | undefined { @@ -183,6 +255,20 @@ export async function recordConversationTrace( repoRoot: string, event: EventMessageUpdated | EventMessagePartUpdated, ): Promise { + if ( + event.type === "message.part.updated" && + event.properties.part.type === "tool" && + event.properties.part.tool === "question" + ) { + const questionToolPayload = buildQuestionToolConversationTracePayload( + event.properties.part, + ); + if (questionToolPayload !== undefined) { + await runConversationTraceHook(repoRoot, questionToolPayload); + return; + } + } + if ( event.type === "message.part.updated" && (event.properties.part.type === "reasoning" || diff --git a/context/context-map.md b/context/context-map.md index 93ab0815..20e68916 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -54,7 +54,7 @@ Feature/domain context: - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, fixed preset catalog/messages, and precedence rules) - `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and Claude generated settings boundary including Agent Trace hooks plus `PreToolUse` Bash policy hook registration) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends `-patch` variant `message.updated` + per-diff `message.part.updated` with `part_type: "patch"` concurrently via `Promise.all`, when no diffs sends original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` as `{ type: "message.part.updated", payloads: [{ session_id, message_id, part_type, text, generated_at_unix_ms }] }` with `text`/`reasoning` only; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts mapped to `part_type: "text"` and JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current `sce` install/distribution contract covering supported Nix/Cargo/npm plus source-built Flatpak channel scope, implemented GitHub Release Flatpak source-manifest and source-built `.flatpak` bundle asset packaging, canonical `.version` release authority, manual GitHub Release `prerelease` checkbox behavior, Nix-owned build/release policy, Nix-owned Flatpak manifest generation via `pkgs.formats.yaml.generate` plus Nix-owned cargo-sources generation from `cli/Cargo.lock` plus dedicated Nix-built Bash validator scripts for static, version-parity, and local-manifest validation, thin imperative `packaging/flatpak/sce-flatpak.sh` orchestration around `flatpak-builder` / `flatpak build-bundle`, the reduced Linux flake app surface (`sce-flatpak`, `release-flatpak-package`, `release-flatpak-bundle`, plus `regenerate-flatpak-manifest` / `regenerate-cargo-sources` helpers) with `flatpak-static-validation` / `flatpak-manifest-parity` / `cargo-sources-parity` checks, the implemented `packaging/flatpak/dev.crocoder.sce.yml` + AppStream/Cargo-source packaging surface as generated artifacts, and the `dev.crocoder.sce` host-git bridge decision) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for existing binary install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, the explicit non-default execution boundary, and the separate Flatpak source-build validation boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index 1b36e952..00e37e56 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -199,7 +199,7 @@ - `get_or_create_encryption_key`: Public keyring-backed helper in `cli/src/services/db/encryption_key.rs` that retrieves or generates a 64-character hex encryption key from the OS credential store (macOS Keychain, Linux Secret Service via zbus, Windows Credential Store); uses `keyring_core::Entry` with service name `"sce"` and the database name as username. Actively consumed by `EncryptedTursoDb::new()` via the shared adapter constructor. - `conversation-trace mixed batch`: Current Rust `sce hooks conversation-trace` STDIN contract: a top-level JSON object with `payloads: [{ type, ... }]`, where each persisted item declares `type` as `message` or `message.part`; top-level `type` is ignored, unsupported/malformed items are skipped while valid siblings continue, and valid message/part items are persisted as separate AgentTraceDb batches. - `conversation-trace raw Claude event path`: Classification branch in `parse_conversation_trace_payload` that detects top-level `hook_event_name` and routes to the matching transformer via `match`: `"UserPromptSubmit"` → `transform_claude_user_prompt_submit`, `"Stop"` → `transform_claude_stop`, `"PostToolUse"` → `transform_claude_post_tool_use`, unsupported → error listing supported events. All transformers validate required fields, generate a UUIDv7 `message_id` and parse-time `generated_at_unix_ms`, and produce one parent `message` plus one or more `message.part` items. `UserPromptSubmit` produces `role: "user"` and `text` from `prompt`; `Stop` produces `role: "assistant"` and `text` from `last_assistant_message`; `PostToolUse` only produces items when `tool_name` is `Write` or `Edit` (silently skips other tools like `Read`, `Think`), with `role: "assistant"` and `part_type: "patch"`. Unlike the `UserPromptSubmit`/`Stop` paths, the `PostToolUse` transform delegates to `build_claude_post_tool_use_patch` (from `structured_patch.rs`): on `PatchBuildResult::Built(parsed_patch)` produces one `message.part` with `text` set to JSON-serialized `ParsedPatch`, and on `PatchBuildResult::Skipped(_)` silently returns zero items. -- `agent-trace plugin conversation-trace handoff seam`: Helper path in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` where `recordConversationTrace(repoRoot, event)` builds normalized snake_case mixed-batch envelopes for `sce hooks conversation-trace`: ordinary `message` and eligible `message.part` events each send one `payloads[0]` item with its own `type`, while diff-backed `message` events send one envelope containing the synthetic parent `message` item plus patch `message.part` items; `message` handoff runs before the plugin's existing diff-trace flow, while part events do not invoke diff-trace. +- `agent-trace plugin conversation-trace handoff seam`: Helper path in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` where `recordConversationTrace(repoRoot, event)` builds normalized snake_case mixed-batch envelopes for `sce hooks conversation-trace`: ordinary `message` and eligible `message.part` events each send one `payloads[0]` item with its own `type`, diff-backed `message` events send one envelope containing the synthetic parent `message` item plus patch `message.part` items, and completed OpenCode `question` tool parts are mapped to one `message.part` payload with `part_type: "text"` plus JSON-stringified `{ question, answer }[]` text; `message` handoff runs before the plugin's existing diff-trace flow, while part events do not invoke diff-trace. - `agent-trace plugin diff-trace hook handoff seam`: Internal helper `runDiffTraceHook` in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` that invokes `sce hooks diff-trace`, streams `{ sessionID, diff, time, model_id, tool_name, tool_version }` to STDIN JSON (`tool_name` fixed to `opencode`, `tool_version` session-derived when available), and surfaces deterministic invocation failures. - `agent-trace plugin secondary diff artifact ownership`: Current runtime contract where `buildTrace` no longer writes diff-trace artifacts or database rows directly; extracted diff payloads are forwarded to CLI `diff-trace` intake and the Rust hook runtime owns AgentTraceDb insertion plus collision-safe per-invocation artifact persistence. - `messages table (Agent Trace DB)`: Agent Trace DB table created by migration `008_create_messages.sql`; stores session-scoped parent messages with columns `session_id`, `message_id`, `role` (`user`/`assistant` via CHECK constraint), `generated_at_unix_ms`, `created_at`, and `updated_at`. Message body text belongs to `parts.text`, not the parent `messages` row. Has a unique index on `(session_id, message_id)` for duplicate-ignore parent message inserts and a compound index on `(session_id, generated_at_unix_ms, id)` for chronological session message retrieval. No foreign keys to any other table. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index 5b122894..f576002f 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -13,7 +13,7 @@ The Claude TypeScript agent-trace runtime was removed in T07 of the `claude-rust - For every captured `message` event, the plugin checks for `summary.diffs` via `buildPatchConversationTracePayload`: - **When diffs exist**: builds one mixed `-patch` conversation-trace envelope containing the synthetic parent `message` item with `message_id = "${id}-patch"` plus all per-diff `message.part` patch items, then invokes `sce hooks conversation-trace` once. The original `message` event is replaced — no original `message` payload is sent. - **When no diffs exist**: builds one mixed envelope containing a single `message` item via `buildConversationTracePayload` and invokes `sce hooks conversation-trace` over STDIN JSON. -- For every captured `message.part` event, the plugin builds one mixed envelope containing a single `message.part` item via `buildMessagePartConversationTracePayload` and invokes `sce hooks conversation-trace` over STDIN JSON; only `text` and `reasoning` part types with non-empty `text` are dispatched. +- For captured `message.part` events, the plugin dispatches only supported part shapes to `sce hooks conversation-trace`: ordinary `text` and `reasoning` parts with non-empty `text`, plus completed OpenCode `question` tool parts mapped to a Rust-compatible `part_type: "text"` payload. - Existing diff-trace capture remains filtered to user messages with usable diffs. - When diff extraction succeeds, the plugin invokes `sce hooks diff-trace` after conversation-trace handoff and sends `{ sessionID, diff, time, model_id, tool_name, tool_version }` over STDIN JSON (`tool_name` is always `"opencode"`; `tool_version` is captured from session lifecycle events when available). - The plugin no longer writes diff-trace artifacts or database rows directly; the Rust `diff-trace` hook path owns AgentTraceDb insertion plus collision-safe timestamp+attempt artifact writes. @@ -71,13 +71,36 @@ The `buildPatchConversationTracePayload(event)` helper processes `message` event When `buildPatchConversationTracePayload` returns `undefined` (no diff entries), `recordConversationTrace` falls back to sending the original `message` payload as one mixed envelope via `buildConversationTracePayload`. +## Question tool conversation trace + +Completed OpenCode question-tool results are captured through the existing `message.part` mixed-batch path without adding a new Rust part type. + +Capture requires all of these guards: + +1. `event.type === "message.part.updated"` +2. `event.properties.part.type === "tool"` +3. `event.properties.part.tool === "question"` +4. `event.properties.part.state.status === "completed"` + +`extractQuestionToolAnswers(eventPart)` accepts the narrowed OpenCode tool-part type and reads the question-tool state directly. It uses `state.input.questions` only when that property exists and is an array, and uses `state.metadata.answers` only when that property exists and is an array. Empty or length-mismatched question/answer arrays return `undefined`. Otherwise, entries are paired by index: entries with a string `question` field are retained, missing/non-string question entries are skipped, and each answer entry is joined with `", "` when it is an array (non-array answer entries become an empty answer string). Unrelated tool events are rejected by the earlier `message.part.updated` + `type === "tool"` + `tool === "question"` guards and are not dispatched. + +When extraction succeeds, `buildQuestionToolConversationTracePayload(eventPart)` emits one `message.part` item: + +- `type: "message.part"` +- `session_id` from `event.properties.part.sessionID` +- `message_id` from `event.properties.part.messageID` +- `part_type: "text"` +- `text: JSON.stringify(Array<{ question: string; answer: string }>)` +- `generated_at_unix_ms = Date.now()` + ## Current usage boundary - `recordConversationTrace(repoRoot, event)` branches on event type: - For `message` events: calls `buildPatchConversationTracePayload` first. - If a patch payload is returned (diff entries exist), dispatches it once — the original `message` payload is not sent. - If `undefined` (no diffs), sends the original `message` payload as one mixed envelope via `buildConversationTracePayload`. - - For `message.part` events (only `text` and `reasoning` with non-empty `text`): uses `buildMessagePartConversationTracePayload`. + - For `message.part` question-tool events: checks the `tool === "question"` guard before attempting `buildQuestionToolConversationTracePayload`; completed well-formed results dispatch as one `part_type: "text"` item and skipped results fall through without dispatch. + - For `message.part` text/reasoning events (only `text` and `reasoning` with non-empty `text`): uses `buildMessagePartConversationTracePayload`. - The `message` conversation-trace batch (no-diff fallback) maps OpenCode event fields mechanically into a `payloads[0]` item with `type: "message"`, `session_id`, `message_id`, `role`, and `generated_at_unix_ms`; it does not emit message-level `agent` or `summary_diffs` fields and does not duplicate Rust hook validation. - `buildMessagePartConversationTracePayload(event)` maps `event.properties.part.sessionID`, `messageID`, `type`, and `text` into a `payloads[0]` item with `type: "message.part"`, `session_id`, `message_id`, `part_type`, and `text`, and uses `Date.now()` for `generated_at_unix_ms`. - The diff extraction seam is internal to the source module and is used by `buildTrace` at runtime. From 238b357c144bce8e31ef1b0502765787671e750e Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Fri, 26 Jun 2026 15:51:51 +0200 Subject: [PATCH 2/2] agent-trace: Add question part type for conversation-trace question tool results The OpenCode question tool was previously mapped to part_type: "text", losing the semantic distinction. A first-class "question" part type preserves the type identity and enables query/analysis against question tool results. - Rust: Add PartType::Question variant, validate_question_part_text helper (requires JSON array of {question, answer} objects), relax parts.type CHECK constraint in 009 migration - TypeScript: Update part_type literal (plugin + generated copies), refactor buildMessagePartConversationTracePayload to accept EventAllowedPart, emit part_type: "question" in question tool payload - Context docs: Reflect new question part type across all affected context files --- .opencode/plugins/sce-agent-trace.ts | 13 +-- .../agent-trace/009_create_parts.sql | 2 +- cli/src/services/agent_trace_db/mod.rs | 2 + cli/src/services/hooks/mod.rs | 90 ++++++++++++++++--- config/.opencode/plugins/sce-agent-trace.ts | 13 +-- .../.opencode/plugins/sce-agent-trace.ts | 13 +-- .../opencode-sce-agent-trace-plugin.ts | 13 +-- context/context-map.md | 4 +- context/glossary.md | 4 +- context/sce/agent-trace-db.md | 4 +- .../sce/agent-trace-hooks-command-routing.md | 5 +- .../opencode-agent-trace-plugin-runtime.md | 8 +- 12 files changed, 123 insertions(+), 48 deletions(-) diff --git a/.opencode/plugins/sce-agent-trace.ts b/.opencode/plugins/sce-agent-trace.ts index a4932024..ab5d6143 100644 --- a/.opencode/plugins/sce-agent-trace.ts +++ b/.opencode/plugins/sce-agent-trace.ts @@ -35,7 +35,7 @@ type ConversationTraceMessagePartUpdatedItem = { type: "message.part"; session_id: string; message_id: string; - part_type: EventMessagePartUpdated["properties"]["part"]["type"]; + part_type: "text" | "reasoning" | "patch" | "question"; text: unknown; generated_at_unix_ms: number; }; @@ -67,6 +67,9 @@ type EventMessagePartUpdated = Extract< type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; type EventMessageToolPart = Extract; +type EventAllowedPart = + | Extract + | Extract; function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], @@ -173,10 +176,8 @@ function buildConversationTracePayload( } export function buildMessagePartConversationTracePayload( - event: EventMessagePartUpdated, + eventPart: EventAllowedPart, ): ConversationTracePayload { - const eventPart = event.properties.part; - return { payloads: [ { @@ -206,7 +207,7 @@ function buildQuestionToolConversationTracePayload( type: "message.part", session_id: eventPart.sessionID, message_id: eventPart.messageID, - part_type: "text", + part_type: "question", text: JSON.stringify(pairedAnswers), generated_at_unix_ms: Date.now(), }, @@ -277,7 +278,7 @@ export async function recordConversationTrace( ) { await runConversationTraceHook( repoRoot, - buildMessagePartConversationTracePayload(event), + buildMessagePartConversationTracePayload(event.properties.part), ); return; } diff --git a/cli/migrations/agent-trace/009_create_parts.sql b/cli/migrations/agent-trace/009_create_parts.sql index 98f6d0ce..82d25066 100644 --- a/cli/migrations/agent-trace/009_create_parts.sql +++ b/cli/migrations/agent-trace/009_create_parts.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS parts ( id INTEGER PRIMARY KEY, - type TEXT NOT NULL CHECK (type IN ('text', 'reasoning', 'patch')), + type TEXT NOT NULL, text TEXT NOT NULL, message_id TEXT NOT NULL, session_id TEXT NOT NULL, diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 2387e701..ecd38f7a 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -272,6 +272,7 @@ pub enum PartType { Text, Reasoning, Patch, + Question, } impl std::fmt::Display for PartType { @@ -280,6 +281,7 @@ impl std::fmt::Display for PartType { Self::Text => write!(f, "text"), Self::Reasoning => write!(f, "reasoning"), Self::Patch => write!(f, "patch"), + Self::Question => write!(f, "question"), } } } diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 1e6a7219..934fde79 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -595,6 +595,7 @@ fn parse_message_part_updated_item( } } PartType::Text | PartType::Reasoning => raw_text, + PartType::Question => validate_question_part_text(raw_text)?, }; Ok(InsertPartInsert { @@ -634,12 +635,40 @@ fn parse_part_type(payload: &serde_json::Map) -> Result "text" => Ok(PartType::Text), "reasoning" => Ok(PartType::Reasoning), "patch" => Ok(PartType::Patch), + "question" => Ok(PartType::Question), _ => bail!(conversation_trace_validation_error( - "field 'part_type' must be one of 'text', 'reasoning' or 'patch'" + "field 'part_type' must be one of 'text', 'reasoning', 'patch' or 'question'" )), } } +fn validate_question_part_text(raw_text: String) -> Result { + let parsed: Value = serde_json::from_str(&raw_text).map_err(|_| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + let items = parsed.as_array().ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + if items.iter().all(|item| { + item.as_object().is_some_and(|object| { + object.get("question").is_some_and(Value::is_string) + && object.get("answer").is_some_and(Value::is_string) + }) + }) { + return Ok(raw_text); + } + + bail!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) +} + fn conversation_trace_validation_error(detail: &str) -> String { format!("Invalid conversation-trace payload from STDIN: {detail}.") } @@ -2578,6 +2607,13 @@ mod tests { #[test] fn conversation_trace_mixed_payload_maps_to_message_and_part_insert_inputs() { let patch_text = valid_patch_text("src/lib.rs", "let answer = 42;"); + let question_text = serde_json::json!([ + { + "question": "Proceed?", + "answer": "Yes" + } + ]) + .to_string(); let payload = serde_json::json!({ "payloads": [ { @@ -2602,6 +2638,14 @@ mod tests { "part_type": "patch", "text": patch_text, "generated_at_unix_ms": 1_800_000_000_002_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "question", + "text": question_text, + "generated_at_unix_ms": 1_800_000_000_003_i64 } ] }); @@ -2609,7 +2653,7 @@ mod tests { let parsed = parse_conversation_trace_payload(&payload.to_string()) .expect("conversation-trace mixed payload should parse"); - assert_eq!(parsed.attempted_count, 3); + assert_eq!(parsed.attempted_count, 4); assert!(parsed.skipped.is_empty()); assert!(parsed.message_updated.skipped.is_empty()); assert!(parsed.message_part_updated.skipped.is_empty()); @@ -2621,7 +2665,7 @@ mod tests { assert_eq!(message.role, MessageRole::Assistant); assert_eq!(message.generated_at_unix_ms, 1_800_000_000_000_i64); - assert_eq!(parsed.message_part_updated.inserts.len(), 2); + assert_eq!(parsed.message_part_updated.inserts.len(), 3); let reasoning_part = &parsed.message_part_updated.inserts[0]; assert_eq!(reasoning_part.session_id, "session-1"); assert_eq!(reasoning_part.message_id, "message-1"); @@ -2639,10 +2683,22 @@ mod tests { .expect("test patch should serialize") ); assert_eq!(patch_part.generated_at_unix_ms, 1_800_000_000_002_i64); + + let question_part = &parsed.message_part_updated.inserts[2]; + assert_eq!(question_part.session_id, "session-1"); + assert_eq!(question_part.message_id, "message-1"); + assert_eq!(question_part.part_type, PartType::Question); + assert_eq!(question_part.text, question_text); + assert_eq!(question_part.generated_at_unix_ms, 1_800_000_000_003_i64); } #[test] fn conversation_trace_mixed_payload_skips_malformed_sibling_items() { + let invalid_question_text = serde_json::json!({ + "question": "Proceed?", + "answer": "Yes" + }) + .to_string(); let payload = serde_json::json!({ "payloads": [ { @@ -2674,14 +2730,22 @@ mod tests { "text": "--- src/main.rs", "generated_at_unix_ms": 1_800_000_000_004_i64 }, + { + "type": "message.part", + "session_id": "session-5", + "message_id": "message-5", + "part_type": "question", + "text": invalid_question_text, + "generated_at_unix_ms": 1_800_000_000_005_i64 + }, { "type": "session.started", - "session_id": "session-5" + "session_id": "session-6" }, 42, { "type": null, - "session_id": "session-6" + "session_id": "session-7" } ] }); @@ -2689,7 +2753,7 @@ mod tests { let parsed = parse_conversation_trace_payload(&payload.to_string()) .expect("conversation-trace mixed payload should parse with skipped items"); - assert_eq!(parsed.attempted_count, 7); + assert_eq!(parsed.attempted_count, 8); assert_eq!(parsed.message_updated.inserts.len(), 1); assert_eq!(parsed.message_updated.skipped.len(), 1); assert_eq!(parsed.message_updated.skipped[0].index, 1); @@ -2697,7 +2761,7 @@ mod tests { .reason .contains("field 'role'")); assert_eq!(parsed.message_part_updated.inserts.len(), 0); - assert_eq!(parsed.message_part_updated.skipped.len(), 2); + assert_eq!(parsed.message_part_updated.skipped.len(), 3); assert_eq!(parsed.message_part_updated.skipped[0].index, 2); assert!(parsed.message_part_updated.skipped[0] .reason @@ -2706,14 +2770,18 @@ mod tests { assert!(parsed.message_part_updated.skipped[1] .reason .contains("neither valid patch-JSON nor a valid patch")); + assert_eq!(parsed.message_part_updated.skipped[2].index, 4); + assert!(parsed.message_part_updated.skipped[2] + .reason + .contains("question part must be a JSON array")); assert_eq!(parsed.skipped.len(), 3); - assert_eq!(parsed.skipped[0].index, 4); + assert_eq!(parsed.skipped[0].index, 5); assert!(parsed.skipped[0].reason.contains("field 'type'")); - assert_eq!(parsed.skipped[1].index, 5); + assert_eq!(parsed.skipped[1].index, 6); assert!(parsed.skipped[1] .reason - .contains("payloads[5] must be an object")); - assert_eq!(parsed.skipped[2].index, 6); + .contains("payloads[6] must be an object")); + assert_eq!(parsed.skipped[2].index, 7); assert!(parsed.skipped[2] .reason .contains("field 'type' must be a string")); diff --git a/config/.opencode/plugins/sce-agent-trace.ts b/config/.opencode/plugins/sce-agent-trace.ts index a4932024..ab5d6143 100644 --- a/config/.opencode/plugins/sce-agent-trace.ts +++ b/config/.opencode/plugins/sce-agent-trace.ts @@ -35,7 +35,7 @@ type ConversationTraceMessagePartUpdatedItem = { type: "message.part"; session_id: string; message_id: string; - part_type: EventMessagePartUpdated["properties"]["part"]["type"]; + part_type: "text" | "reasoning" | "patch" | "question"; text: unknown; generated_at_unix_ms: number; }; @@ -67,6 +67,9 @@ type EventMessagePartUpdated = Extract< type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; type EventMessageToolPart = Extract; +type EventAllowedPart = + | Extract + | Extract; function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], @@ -173,10 +176,8 @@ function buildConversationTracePayload( } export function buildMessagePartConversationTracePayload( - event: EventMessagePartUpdated, + eventPart: EventAllowedPart, ): ConversationTracePayload { - const eventPart = event.properties.part; - return { payloads: [ { @@ -206,7 +207,7 @@ function buildQuestionToolConversationTracePayload( type: "message.part", session_id: eventPart.sessionID, message_id: eventPart.messageID, - part_type: "text", + part_type: "question", text: JSON.stringify(pairedAnswers), generated_at_unix_ms: Date.now(), }, @@ -277,7 +278,7 @@ export async function recordConversationTrace( ) { await runConversationTraceHook( repoRoot, - buildMessagePartConversationTracePayload(event), + buildMessagePartConversationTracePayload(event.properties.part), ); return; } diff --git a/config/automated/.opencode/plugins/sce-agent-trace.ts b/config/automated/.opencode/plugins/sce-agent-trace.ts index a4932024..ab5d6143 100644 --- a/config/automated/.opencode/plugins/sce-agent-trace.ts +++ b/config/automated/.opencode/plugins/sce-agent-trace.ts @@ -35,7 +35,7 @@ type ConversationTraceMessagePartUpdatedItem = { type: "message.part"; session_id: string; message_id: string; - part_type: EventMessagePartUpdated["properties"]["part"]["type"]; + part_type: "text" | "reasoning" | "patch" | "question"; text: unknown; generated_at_unix_ms: number; }; @@ -67,6 +67,9 @@ type EventMessagePartUpdated = Extract< type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; type EventMessageToolPart = Extract; +type EventAllowedPart = + | Extract + | Extract; function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], @@ -173,10 +176,8 @@ function buildConversationTracePayload( } export function buildMessagePartConversationTracePayload( - event: EventMessagePartUpdated, + eventPart: EventAllowedPart, ): ConversationTracePayload { - const eventPart = event.properties.part; - return { payloads: [ { @@ -206,7 +207,7 @@ function buildQuestionToolConversationTracePayload( type: "message.part", session_id: eventPart.sessionID, message_id: eventPart.messageID, - part_type: "text", + part_type: "question", text: JSON.stringify(pairedAnswers), generated_at_unix_ms: Date.now(), }, @@ -277,7 +278,7 @@ export async function recordConversationTrace( ) { await runConversationTraceHook( repoRoot, - buildMessagePartConversationTracePayload(event), + buildMessagePartConversationTracePayload(event.properties.part), ); return; } diff --git a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts index a4932024..ab5d6143 100644 --- a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts +++ b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts @@ -35,7 +35,7 @@ type ConversationTraceMessagePartUpdatedItem = { type: "message.part"; session_id: string; message_id: string; - part_type: EventMessagePartUpdated["properties"]["part"]["type"]; + part_type: "text" | "reasoning" | "patch" | "question"; text: unknown; generated_at_unix_ms: number; }; @@ -67,6 +67,9 @@ type EventMessagePartUpdated = Extract< type EventMessagePart = EventMessagePartUpdated["properties"]["part"]; type EventMessageToolPart = Extract; +type EventAllowedPart = + | Extract + | Extract; function extractDiffEntries( eventInfo: EventMessageUpdated["properties"]["info"], @@ -173,10 +176,8 @@ function buildConversationTracePayload( } export function buildMessagePartConversationTracePayload( - event: EventMessagePartUpdated, + eventPart: EventAllowedPart, ): ConversationTracePayload { - const eventPart = event.properties.part; - return { payloads: [ { @@ -206,7 +207,7 @@ function buildQuestionToolConversationTracePayload( type: "message.part", session_id: eventPart.sessionID, message_id: eventPart.messageID, - part_type: "text", + part_type: "question", text: JSON.stringify(pairedAnswers), generated_at_unix_ms: Date.now(), }, @@ -277,7 +278,7 @@ export async function recordConversationTrace( ) { await runConversationTraceHook( repoRoot, - buildMessagePartConversationTracePayload(event), + buildMessagePartConversationTracePayload(event.properties.part), ); return; } diff --git a/context/context-map.md b/context/context-map.md index 20e68916..f30c9a52 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -50,11 +50,11 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, and always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active Agent Trace hook DB paths using no-migration readiness-gated AgentTraceDb access, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, dual persistence to AgentTraceDb, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, `session-model` STDIN intake for normalized model attribution and raw Claude `SessionStart` events, and `conversation-trace` STDIN intake that classifies by `hook_event_name` — raw Claude `UserPromptSubmit` events (`transform_claude_user_prompt_submit`) and `Stop` events (`transform_claude_stop`) are transformed into normalized `message` + `message.part` items (user or assistant role, text part) and forwarded through the existing mixed-batch parser, patch `message.part` text is parsed to JSON-serialized `ParsedPatch` before persistence, unsupported raw Claude hook events fail deterministically with diagnostics listing supported events, and payloads without `hook_event_name` follow the existing `{ payloads: [{ type, ... }] }` mixed-batch validation/persistence path) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active Agent Trace hook DB paths using no-migration readiness-gated AgentTraceDb access, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, dual persistence to AgentTraceDb, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, `session-model` STDIN intake for normalized model attribution and raw Claude `SessionStart` events, and `conversation-trace` STDIN intake that classifies by `hook_event_name` — raw Claude `UserPromptSubmit` events (`transform_claude_user_prompt_submit`) and `Stop` events (`transform_claude_stop`) are transformed into normalized `message` + `message.part` items (user or assistant role, text part) and forwarded through the existing mixed-batch parser, mixed-batch `message.part` accepts `text`/`reasoning`/`patch`/`question` with patch JSON normalization and question JSON-shape validation before persistence, unsupported raw Claude hook events fail deterministically with diagnostics listing supported events, and payloads without `hook_event_name` follow the existing `{ payloads: [{ type, ... }] }` mixed-batch validation/persistence path) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, fixed preset catalog/messages, and precedence rules) - `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and Claude generated settings boundary including Agent Trace hooks plus `PreToolUse` Bash policy hook registration) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts mapped to `part_type: "text"` and JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current `sce` install/distribution contract covering supported Nix/Cargo/npm plus source-built Flatpak channel scope, implemented GitHub Release Flatpak source-manifest and source-built `.flatpak` bundle asset packaging, canonical `.version` release authority, manual GitHub Release `prerelease` checkbox behavior, Nix-owned build/release policy, Nix-owned Flatpak manifest generation via `pkgs.formats.yaml.generate` plus Nix-owned cargo-sources generation from `cli/Cargo.lock` plus dedicated Nix-built Bash validator scripts for static, version-parity, and local-manifest validation, thin imperative `packaging/flatpak/sce-flatpak.sh` orchestration around `flatpak-builder` / `flatpak build-bundle`, the reduced Linux flake app surface (`sce-flatpak`, `release-flatpak-package`, `release-flatpak-bundle`, plus `regenerate-flatpak-manifest` / `regenerate-cargo-sources` helpers) with `flatpak-static-validation` / `flatpak-manifest-parity` / `cargo-sources-parity` checks, the implemented `packaging/flatpak/dev.crocoder.sce.yml` + AppStream/Cargo-source packaging surface as generated artifacts, and the `dev.crocoder.sce` host-git bridge decision) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for existing binary install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, the explicit non-default execution boundary, and the separate Flatpak source-build validation boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index 00e37e56..d073a996 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -199,8 +199,8 @@ - `get_or_create_encryption_key`: Public keyring-backed helper in `cli/src/services/db/encryption_key.rs` that retrieves or generates a 64-character hex encryption key from the OS credential store (macOS Keychain, Linux Secret Service via zbus, Windows Credential Store); uses `keyring_core::Entry` with service name `"sce"` and the database name as username. Actively consumed by `EncryptedTursoDb::new()` via the shared adapter constructor. - `conversation-trace mixed batch`: Current Rust `sce hooks conversation-trace` STDIN contract: a top-level JSON object with `payloads: [{ type, ... }]`, where each persisted item declares `type` as `message` or `message.part`; top-level `type` is ignored, unsupported/malformed items are skipped while valid siblings continue, and valid message/part items are persisted as separate AgentTraceDb batches. - `conversation-trace raw Claude event path`: Classification branch in `parse_conversation_trace_payload` that detects top-level `hook_event_name` and routes to the matching transformer via `match`: `"UserPromptSubmit"` → `transform_claude_user_prompt_submit`, `"Stop"` → `transform_claude_stop`, `"PostToolUse"` → `transform_claude_post_tool_use`, unsupported → error listing supported events. All transformers validate required fields, generate a UUIDv7 `message_id` and parse-time `generated_at_unix_ms`, and produce one parent `message` plus one or more `message.part` items. `UserPromptSubmit` produces `role: "user"` and `text` from `prompt`; `Stop` produces `role: "assistant"` and `text` from `last_assistant_message`; `PostToolUse` only produces items when `tool_name` is `Write` or `Edit` (silently skips other tools like `Read`, `Think`), with `role: "assistant"` and `part_type: "patch"`. Unlike the `UserPromptSubmit`/`Stop` paths, the `PostToolUse` transform delegates to `build_claude_post_tool_use_patch` (from `structured_patch.rs`): on `PatchBuildResult::Built(parsed_patch)` produces one `message.part` with `text` set to JSON-serialized `ParsedPatch`, and on `PatchBuildResult::Skipped(_)` silently returns zero items. -- `agent-trace plugin conversation-trace handoff seam`: Helper path in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` where `recordConversationTrace(repoRoot, event)` builds normalized snake_case mixed-batch envelopes for `sce hooks conversation-trace`: ordinary `message` and eligible `message.part` events each send one `payloads[0]` item with its own `type`, diff-backed `message` events send one envelope containing the synthetic parent `message` item plus patch `message.part` items, and completed OpenCode `question` tool parts are mapped to one `message.part` payload with `part_type: "text"` plus JSON-stringified `{ question, answer }[]` text; `message` handoff runs before the plugin's existing diff-trace flow, while part events do not invoke diff-trace. +- `agent-trace plugin conversation-trace handoff seam`: Helper path in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` where `recordConversationTrace(repoRoot, event)` builds normalized snake_case mixed-batch envelopes for `sce hooks conversation-trace`: ordinary `message` and eligible `message.part` events each send one `payloads[0]` item with its own `type`, diff-backed `message` events send one envelope containing the synthetic parent `message` item plus patch `message.part` items, and completed OpenCode `question` tool parts are mapped to one `message.part` payload with `part_type: "question"` plus JSON-stringified `{ question, answer }[]` text; `message` handoff runs before the plugin's existing diff-trace flow, while part events do not invoke diff-trace. - `agent-trace plugin diff-trace hook handoff seam`: Internal helper `runDiffTraceHook` in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` that invokes `sce hooks diff-trace`, streams `{ sessionID, diff, time, model_id, tool_name, tool_version }` to STDIN JSON (`tool_name` fixed to `opencode`, `tool_version` session-derived when available), and surfaces deterministic invocation failures. - `agent-trace plugin secondary diff artifact ownership`: Current runtime contract where `buildTrace` no longer writes diff-trace artifacts or database rows directly; extracted diff payloads are forwarded to CLI `diff-trace` intake and the Rust hook runtime owns AgentTraceDb insertion plus collision-safe per-invocation artifact persistence. - `messages table (Agent Trace DB)`: Agent Trace DB table created by migration `008_create_messages.sql`; stores session-scoped parent messages with columns `session_id`, `message_id`, `role` (`user`/`assistant` via CHECK constraint), `generated_at_unix_ms`, `created_at`, and `updated_at`. Message body text belongs to `parts.text`, not the parent `messages` row. Has a unique index on `(session_id, message_id)` for duplicate-ignore parent message inserts and a compound index on `(session_id, generated_at_unix_ms, id)` for chronological session message retrieval. No foreign keys to any other table. -- `parts table (Agent Trace DB)`: Agent Trace DB table created by migration `009_create_parts.sql`; stores append-only message parts with columns `type` (`text`/`reasoning`/`patch` via CHECK constraint), `text`, `message_id`, `session_id`, `generated_at_unix_ms`, `created_at`, `updated_at`. Uses only the internal `id` for row identity (no upsert/dedup). Multiple parts can exist for the same `(session_id, message_id)`. A compound index on `(session_id, message_id, generated_at_unix_ms, id)` enables ordered joins. No foreign keys to `messages` or any other table, so parts may be inserted before their parent message exists. +- `parts table (Agent Trace DB)`: Agent Trace DB table created by migration `009_create_parts.sql`; stores append-only message parts with columns `type` (typed by Rust as `text`/`reasoning`/`patch`/`question` and stored as unconstrained `TEXT NOT NULL`), `text`, `message_id`, `session_id`, `generated_at_unix_ms`, `created_at`, `updated_at`. Uses only the internal `id` for row identity (no upsert/dedup). Multiple parts can exist for the same `(session_id, message_id)`. A compound index on `(session_id, message_id, generated_at_unix_ms, id)` enables ordered joins. No foreign keys to `messages` or any other table, so parts may be inserted before their parent message exists. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index e215b881..678a1889 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -28,7 +28,7 @@ pub type AgentTraceDb = TursoDb; - `INSERT_MESSAGE_SQL`: parameterized single-row SQL using `INSERT ... ON CONFLICT (session_id, message_id) DO NOTHING` — leverages the unique index `idx_messages_session_message` so duplicate parent-message events remain non-failing without mutating the existing row. - `insert_message(input)`: typed single-row helper that executes the duplicate-ignore parent-message insert; retained as part of the adapter surface. - `insert_messages(inputs)`: typed batch helper that generates and executes one parameterized multi-row `messages` insert for valid conversation-trace `message` batches while preserving duplicate-ignore semantics. -- `PartType` enum: `Text` / `Reasoning` / `Patch` — maps to `parts.type` DB constraint. +- `PartType` enum: `Text` / `Reasoning` / `Patch` / `Question` — serializes known conversation part kinds for typed inserts. `parts.type` is stored as `TEXT NOT NULL` without a database-level enum `CHECK` constraint. - `InsertPartInsert`: owned payload struct with `part_type`, `text`, `session_id`, `message_id`, and `generated_at_unix_ms`. - `INSERT_PART_SQL`: parameterized single-row append-only INSERT into `parts` (no upsert; multiple rows per `(session_id, message_id)` allowed). - `insert_part(input)`: typed single-row helper that inserts a part row without requiring a matching `messages` row (supports out-of-order writes); retained as part of the adapter surface. @@ -136,7 +136,7 @@ The `messages` migration creates: The `parts` migration creates: - `id INTEGER PRIMARY KEY` -- `type TEXT NOT NULL CHECK (type IN ('text', 'reasoning', 'patch'))` +- `type TEXT NOT NULL` - `text TEXT NOT NULL` - `message_id TEXT NOT NULL` - `session_id TEXT NOT NULL` diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 1586087c..89d1411f 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -99,15 +99,16 @@ - Unsupported `hook_event_name` values (not `"UserPromptSubmit"`, `"Stop"`, or `"PostToolUse"`) fail deterministically with an `Invalid conversation-trace payload from STDIN: unsupported Claude hook event '...': supported events are 'UserPromptSubmit', 'Stop' and 'PostToolUse'` error that includes the event name for the existing error-logging path. - **Mixed-batch path** (no `hook_event_name`): Rust intake expects a top-level `payloads` array and per-item `type` discriminators. A top-level `type` field is ignored by the parser; old homogeneous `{ type, payloads }` envelopes are not a compatibility path because same-kind items without their own `type` are skipped rather than classified from the envelope. - `payloads[].type: "message"` parses that item into `InsertMessageInsert` with required non-empty `session_id`, `message_id`, valid `role` (`user|assistant`), and non-negative signed-64-bit `generated_at_unix_ms`; message-level `text`, `agent`, and `summary_diffs` are not required or mapped because body text belongs to `message.part` / `parts.text`. - - `payloads[].type: "message.part"` parses that item into `InsertPartInsert` with required non-empty `session_id`, `message_id`, valid `part_type` (`text|reasoning|patch`), string `text`, and non-negative signed-64-bit `generated_at_unix_ms`. + - `payloads[].type: "message.part"` parses that item into `InsertPartInsert` with required non-empty `session_id`, `message_id`, valid `part_type` (`text|reasoning|patch|question`), string `text`, and non-negative signed-64-bit `generated_at_unix_ms`. - `part_type: "text"` and `part_type: "reasoning"` store the raw `text` string unchanged. - `part_type: "patch"` first tries `load_patch_from_json` — if `payload.text` is already a valid JSON-serialized `ParsedPatch`, stores the raw text unchanged (no re-parse, no re-serialize). If JSON loading fails, falls back to the shared unified-diff parser (`parse_patch_from_text`) and stores JSON-serialized `ParsedPatch` in `parts.text`. If both fail, the item is skipped with a validation error mentioning both patch and patch-JSON formats. + - `part_type: "question"` requires `text` to be a JSON string whose parsed value is an array of objects with string `question` and `answer` fields; valid question text is stored unchanged, while invalid question text is skipped through the same per-item validation path as malformed patch items. - Unsupported item `type` values, missing/non-string item `type`, non-object items, and event-specific item validation failures are recorded as skipped-item diagnostics (`index`, `reason`) while valid sibling items remain eligible for persistence; skipped validation items are logged through `sce.hooks.conversation_trace.payload_skipped`. Top-level JSON/object/`payloads` shape failures fail deterministically with `Invalid conversation-trace payload from STDIN: ...` diagnostics. - Shared persistence (both classification paths converge before DB writes): - Current persistence opens one per-checkout `AgentTraceDb` per hook invocation through lazy checkout DB resolution, then inserts the non-empty valid `message` batch through at most one multi-row `AgentTraceDb::insert_messages(...)` call and the non-empty valid `message.part` batch through at most one multi-row `AgentTraceDb::insert_parts(...)` call. - DB open/initialization failures are command-failing runtime errors logged through `sce.hooks.conversation_trace.error`; valid-item multi-row insert failures are logged once through `sce.hooks.conversation_trace.agent_trace_db_batch_failed`, count the whole valid-item batch as skipped, and do not fail the command. The hook does not fall back to row-by-row insertion after a multi-row insert failure. - Current success output reports deterministic mixed-batch accounting: `conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted=, persisted_messages=, persisted_parts=, skipped=.` The hook does not persist `context/tmp` artifacts. - - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff: ordinary message/part events produce one-item mixed envelopes, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. + - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. - `session-model` reads STDIN JSON and classifies the payload: - **Claude `SessionStart` payloads** (detected by presence of top-level `hook_event_name`): extracts `session_id` from `session_id`/`sessionID`, `model_id` from `model`/`model_id` (including nested `model.id`/`model.model`/`model.name` with `claude/` prefix normalization), `time` from `time`/`timestamp` (falls back to current system time), `tool_name="claude"`, and `tool_version` from `tool_version`/`claude_version`/`version`; when no non-empty payload version is present, Rust best-effort runs `claude --version`, trims stdout, and uses that value if non-empty, otherwise leaving `tool_version` nullable without failing intake. - **OpenCode normalized payloads** (no `hook_event_name`): existing `{ sessionID, time, model_id, tool_name, tool_version }` validation applies unchanged. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index f576002f..8ddfe598 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -13,7 +13,7 @@ The Claude TypeScript agent-trace runtime was removed in T07 of the `claude-rust - For every captured `message` event, the plugin checks for `summary.diffs` via `buildPatchConversationTracePayload`: - **When diffs exist**: builds one mixed `-patch` conversation-trace envelope containing the synthetic parent `message` item with `message_id = "${id}-patch"` plus all per-diff `message.part` patch items, then invokes `sce hooks conversation-trace` once. The original `message` event is replaced — no original `message` payload is sent. - **When no diffs exist**: builds one mixed envelope containing a single `message` item via `buildConversationTracePayload` and invokes `sce hooks conversation-trace` over STDIN JSON. -- For captured `message.part` events, the plugin dispatches only supported part shapes to `sce hooks conversation-trace`: ordinary `text` and `reasoning` parts with non-empty `text`, plus completed OpenCode `question` tool parts mapped to a Rust-compatible `part_type: "text"` payload. +- For captured `message.part` events, the plugin dispatches only supported part shapes to `sce hooks conversation-trace`: ordinary `text` and `reasoning` parts with non-empty `text`, plus completed OpenCode `question` tool parts emitted as first-class `part_type: "question"` payloads. - Existing diff-trace capture remains filtered to user messages with usable diffs. - When diff extraction succeeds, the plugin invokes `sce hooks diff-trace` after conversation-trace handoff and sends `{ sessionID, diff, time, model_id, tool_name, tool_version }` over STDIN JSON (`tool_name` is always `"opencode"`; `tool_version` is captured from session lifecycle events when available). - The plugin no longer writes diff-trace artifacts or database rows directly; the Rust `diff-trace` hook path owns AgentTraceDb insertion plus collision-safe timestamp+attempt artifact writes. @@ -73,7 +73,7 @@ When `buildPatchConversationTracePayload` returns `undefined` (no diff entries), ## Question tool conversation trace -Completed OpenCode question-tool results are captured through the existing `message.part` mixed-batch path without adding a new Rust part type. +Completed OpenCode question-tool results are captured through the existing `message.part` mixed-batch path as first-class `part_type: "question"` items. Capture requires all of these guards: @@ -89,7 +89,7 @@ When extraction succeeds, `buildQuestionToolConversationTracePayload(eventPart)` - `type: "message.part"` - `session_id` from `event.properties.part.sessionID` - `message_id` from `event.properties.part.messageID` -- `part_type: "text"` +- `part_type: "question"` - `text: JSON.stringify(Array<{ question: string; answer: string }>)` - `generated_at_unix_ms = Date.now()` @@ -99,7 +99,7 @@ When extraction succeeds, `buildQuestionToolConversationTracePayload(eventPart)` - For `message` events: calls `buildPatchConversationTracePayload` first. - If a patch payload is returned (diff entries exist), dispatches it once — the original `message` payload is not sent. - If `undefined` (no diffs), sends the original `message` payload as one mixed envelope via `buildConversationTracePayload`. - - For `message.part` question-tool events: checks the `tool === "question"` guard before attempting `buildQuestionToolConversationTracePayload`; completed well-formed results dispatch as one `part_type: "text"` item and skipped results fall through without dispatch. + - For `message.part` question-tool events: checks the `tool === "question"` guard before attempting `buildQuestionToolConversationTracePayload`; completed well-formed results dispatch as one `part_type: "question"` item and skipped results fall through without dispatch. - For `message.part` text/reasoning events (only `text` and `reasoning` with non-empty `text`): uses `buildMessagePartConversationTracePayload`. - The `message` conversation-trace batch (no-diff fallback) maps OpenCode event fields mechanically into a `payloads[0]` item with `type: "message"`, `session_id`, `message_id`, `role`, and `generated_at_unix_ms`; it does not emit message-level `agent` or `summary_diffs` fields and does not duplicate Rust hook validation. - `buildMessagePartConversationTracePayload(event)` maps `event.properties.part.sessionID`, `messageID`, `type`, and `text` into a `payloads[0]` item with `type: "message.part"`, `session_id`, `message_id`, `part_type`, and `text`, and uses `Date.now()` for `generated_at_unix_ms`.