diff --git a/drizzle/0002_bright_master_mold.sql b/drizzle/0002_bright_master_mold.sql new file mode 100644 index 00000000..ca82c48a --- /dev/null +++ b/drizzle/0002_bright_master_mold.sql @@ -0,0 +1,2 @@ +ALTER TABLE `turn` ADD `user_parts` text;--> statement-breakpoint +ALTER TABLE `turn` ADD `assistant_parts` text; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..3f583a21 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,841 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e00593c7-b5bd-4617-8a60-880d47b928a4", + "prevId": "36b52a1d-9075-4d79-9718-4b82d91b6c1d", + "tables": { + "assumption": { + "name": "assumption", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assumption_project_id_project_id_fk": { + "name": "assumption_project_id_project_id_fk", + "tableFrom": "assumption", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assumption_parent_assumption": { + "name": "assumption_parent_assumption", + "columns": { + "assumption_id": { + "name": "assumption_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_assumption_id": { + "name": "parent_assumption_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assumption_parent_assumption_assumption_id_assumption_id_fk": { + "name": "assumption_parent_assumption_assumption_id_assumption_id_fk", + "tableFrom": "assumption_parent_assumption", + "tableTo": "assumption", + "columnsFrom": [ + "assumption_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assumption_parent_assumption_parent_assumption_id_assumption_id_fk": { + "name": "assumption_parent_assumption_parent_assumption_id_assumption_id_fk", + "tableFrom": "assumption_parent_assumption", + "tableTo": "assumption", + "columnsFrom": [ + "parent_assumption_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assumption_parent_assumption_assumption_id_parent_assumption_id_pk": { + "columns": [ + "assumption_id", + "parent_assumption_id" + ], + "name": "assumption_parent_assumption_assumption_id_parent_assumption_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "criterion": { + "name": "criterion", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requirement_id": { + "name": "requirement_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "criterion_project_id_project_id_fk": { + "name": "criterion_project_id_project_id_fk", + "tableFrom": "criterion", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "criterion_requirement_id_requirement_id_fk": { + "name": "criterion_requirement_id_requirement_id_fk", + "tableFrom": "criterion", + "tableTo": "requirement", + "columnsFrom": [ + "requirement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "decision": { + "name": "decision", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "decision_project_id_project_id_fk": { + "name": "decision_project_id_project_id_fk", + "tableFrom": "decision", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "decision_parent_assumption": { + "name": "decision_parent_assumption", + "columns": { + "decision_id": { + "name": "decision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_assumption_id": { + "name": "parent_assumption_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "decision_parent_assumption_decision_id_decision_id_fk": { + "name": "decision_parent_assumption_decision_id_decision_id_fk", + "tableFrom": "decision_parent_assumption", + "tableTo": "decision", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_parent_assumption_parent_assumption_id_assumption_id_fk": { + "name": "decision_parent_assumption_parent_assumption_id_assumption_id_fk", + "tableFrom": "decision_parent_assumption", + "tableTo": "assumption", + "columnsFrom": [ + "parent_assumption_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "decision_parent_assumption_decision_id_parent_assumption_id_pk": { + "columns": [ + "decision_id", + "parent_assumption_id" + ], + "name": "decision_parent_assumption_decision_id_parent_assumption_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "decision_parent_decision": { + "name": "decision_parent_decision", + "columns": { + "decision_id": { + "name": "decision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_decision_id": { + "name": "parent_decision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "decision_parent_decision_decision_id_decision_id_fk": { + "name": "decision_parent_decision_decision_id_decision_id_fk", + "tableFrom": "decision_parent_decision", + "tableTo": "decision", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_parent_decision_parent_decision_id_decision_id_fk": { + "name": "decision_parent_decision_parent_decision_id_decision_id_fk", + "tableFrom": "decision_parent_decision", + "tableTo": "decision", + "columnsFrom": [ + "parent_decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "decision_parent_decision_decision_id_parent_decision_id_pk": { + "columns": [ + "decision_id", + "parent_decision_id" + ], + "name": "decision_parent_decision_decision_id_parent_decision_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "option": { + "name": "option", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "turn_id": { + "name": "turn_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_recommended": { + "name": "is_recommended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_selected": { + "name": "is_selected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "option_turn_position_unique": { + "name": "option_turn_position_unique", + "columns": [ + "turn_id", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "option_turn_id_turn_id_fk": { + "name": "option_turn_id_turn_id_fk", + "tableFrom": "option", + "tableTo": "turn", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project": { + "name": "project", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_turn_id": { + "name": "active_turn_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "requirement": { + "name": "requirement", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "requirement_project_id_project_id_fk": { + "name": "requirement_project_id_project_id_fk", + "tableFrom": "requirement", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "requirement_decision": { + "name": "requirement_decision", + "columns": { + "requirement_id": { + "name": "requirement_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decision_id": { + "name": "decision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "requirement_decision_requirement_id_requirement_id_fk": { + "name": "requirement_decision_requirement_id_requirement_id_fk", + "tableFrom": "requirement_decision", + "tableTo": "requirement", + "columnsFrom": [ + "requirement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "requirement_decision_decision_id_decision_id_fk": { + "name": "requirement_decision_decision_id_decision_id_fk", + "tableFrom": "requirement_decision", + "tableTo": "decision", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "requirement_decision_requirement_id_decision_id_pk": { + "columns": [ + "requirement_id", + "decision_id" + ], + "name": "requirement_decision_requirement_id_decision_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turn": { + "name": "turn", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_turn_id": { + "name": "parent_turn_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "why": { + "name": "why", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_resolution": { + "name": "is_resolution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_parts": { + "name": "user_parts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_parts": { + "name": "assistant_parts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "turn_project_id_project_id_fk": { + "name": "turn_project_id_project_id_fk", + "tableFrom": "turn", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "turn_parent_turn_id_turn_id_fk": { + "name": "turn_parent_turn_id_turn_id_fk", + "tableFrom": "turn", + "tableTo": "turn", + "columnsFrom": [ + "parent_turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turn_assumption": { + "name": "turn_assumption", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assumption_id": { + "name": "assumption_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "turn_assumption_turn_id_turn_id_fk": { + "name": "turn_assumption_turn_id_turn_id_fk", + "tableFrom": "turn_assumption", + "tableTo": "turn", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "turn_assumption_assumption_id_assumption_id_fk": { + "name": "turn_assumption_assumption_id_assumption_id_fk", + "tableFrom": "turn_assumption", + "tableTo": "assumption", + "columnsFrom": [ + "assumption_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "turn_assumption_turn_id_assumption_id_pk": { + "columns": [ + "turn_id", + "assumption_id" + ], + "name": "turn_assumption_turn_id_assumption_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turn_decision": { + "name": "turn_decision", + "columns": { + "turn_id": { + "name": "turn_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decision_id": { + "name": "decision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "turn_decision_turn_id_turn_id_fk": { + "name": "turn_decision_turn_id_turn_id_fk", + "tableFrom": "turn_decision", + "tableTo": "turn", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "turn_decision_decision_id_decision_id_fk": { + "name": "turn_decision_decision_id_decision_id_fk", + "tableFrom": "turn_decision", + "tableTo": "decision", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "turn_decision_turn_id_decision_id_pk": { + "columns": [ + "turn_id", + "decision_id" + ], + "name": "turn_decision_turn_id_decision_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f5f1de20..6c32a70c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1775048687009, "tag": "0001_clear_romulus", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1775127507844, + "tag": "0002_bright_master_mold", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index 886af036..98d6a870 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -100,14 +100,14 @@ - Branch: `ln/fe-554-structured-interview` - **Verification approach**: inner — schema validation on agent tool output (Zod parse, establishes I16); unit tests for phase-tagged turn persistence. Middle — round-trip: structured turn → persist → active path query → verify phase provenance intact. Outer — manual interview walkthrough, assess question quality. → SPEC.md §Oracle Strategy, §Acknowledged Blind Spots (interview quality) -4a. **Parts-based persistence + context builders** — Schema migration: add `user_parts` and `assistant_parts` JSON columns to turn table. Server-side: assemble final assistant `parts[]` from DomainEvents on stream finish, persist alongside scalars. Define `BrunchUIMessage` type with custom Data Parts (`data-option-selection`, `data-confirmation`). Extract `formatHistory()` into typed context builders (`buildInterviewerContext`, `buildObserverContext`). No backward-compatible fallback — DB can be re-initialized if needed. `not-started` +4a. **Parts-based persistence + context builders** `FE-555` — Schema migration: add `user_parts` and `assistant_parts` JSON columns to turn table. Server-side: assemble final assistant `parts[]` from DomainEvents on stream finish, persist alongside scalars. Define `BrunchUIMessage` type with custom Data Parts (`data-option-selection`, `data-confirmation`). Extract `formatHistory()` into typed context builders (`buildInterviewerContext`, `buildObserverContext`). No backward-compatible fallback — DB can be re-initialized if needed. `done` - Requirements: → SPEC.md §Requirements #4, #14 - Assumptions: → SPEC.md §Assumptions A22, A23 - Decisions: → SPEC.md §Decisions D23, D24, D25 - Invariants established: → SPEC.md §Invariants I17, I18, I19 - Invariants respected: → SPEC.md §Invariants I1, I5, I6, I11, I12, I13, I16 - Acceptance: schema migration adds parts columns; assistant parts persisted on stream finish (reasoning, tool-call states, text); Data Part schemas validated via Zod on write/read (I17); parts round-trip: DomainEvents → assemble → persist → load → hydrate matches original (I18); `buildInterviewerContext()` produces equivalent output to current `formatHistory()` (I19); observer context builder produces extraction-optimized projection - - Branch: `ln/fe-554-structured-interview` (continues current branch) + - Branch: `ln/fe-555-parts-persistence` - **Verification approach**: inner — round-trip oracle for parts fidelity (I18); Zod schema validation on Data Parts (I17); unit tests for context builder output shape and equivalence (I19). → SPEC.md §Oracle Strategy (inner: fast unit tests — parts). Middle — integration: full `conductTurn()` → parts persisted → reload → hydration matches live state. Outer — manual resume test via `/cli-cdp` (reasoning + tool states visible on refresh). → SPEC.md §Acknowledged Blind Spots (parts/scalar consistency). 4b. **Structured interview: client UI** — Turn card rendering (question + options + grounding + impact badge). Option selection UI using `data-option-selection` Data Part (persist `is_selected` + structured answer). Outer-loop visual verification via `/cli-cdp`. `not-started` diff --git a/memory/SPEC.md b/memory/SPEC.md index 926cff2b..bff7c163 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -85,8 +85,8 @@ The architecture (layered: db → core → adapters): | A19 | `AsyncIterable` from core can be consumed by both SSE streaming (web) and line-by-line terminal output (CLI) without buffering issues | **validated** | D19 | Core extraction | Validated: conductTurn() yields DomainEvents consumed by Express SSE adapter; 12 new core tests + 9 app integration tests pass | | A20 | Observer results can be delivered as typed data parts on the existing chat SSE stream without holding the connection open unacceptably long — observer is synchronous, runs within the same `conductTurn()` request, completes during user read time | high | D22 | Observer agent, Entity sidebar | Measure observer latency in slice 5; if >5s, fall back to out-of-band SSE (Option 2 in research doc) | | A21 | `useChat` `onData` callback reliably bridges to `queryClient.setQueryData` without stale-closure issues — known `onFinish` stale-closure bug (ai-sdk#550) may or may not affect `onData` | medium | D22 | Entity sidebar | Test in slice 6: verify `setQueryData` from `onData` updates sidebar reactively; if stale, use parallel `EventSource` instead | -| A22 | AI SDK `UIMessage.parts[]` with custom Data Parts (typed via `dataPartsSchema`) persisted as JSON on the turn table is sufficient for faithful UI resume — no separate `turn_message` table needed for current scope | high | D23, D24 | Parts persistence | Validate by implementing parts persistence in slice 4a: hydrate `useChat` from persisted parts, verify reasoning + tool-call state round-trip on refresh | -| A23 | Custom Data Parts for structured user input (option selection, confirmation) can replace scalar `turn.answer` as the primary user-response model without breaking `formatHistory()` or observer context | high | D24 | Parts persistence | Validate in slice 4a: structured user input round-trips through persistence → hydration → re-rendering | +| A22 | AI SDK `UIMessage.parts[]` with custom Data Parts (typed via `dataPartsSchema`) persisted as JSON on the turn table is sufficient for faithful UI resume — no separate `turn_message` table needed for current scope | **validated** | D23, D24 | Parts persistence | Validated: parts assembler converts DomainEvents to typed parts, round-trips through JSON persistence (I18). Client hydration from parts deferred to 4b (outer-loop). | +| A23 | Custom Data Parts for structured user input (option selection, confirmation) can replace scalar `turn.answer` as the primary user-response model without breaking `formatHistory()` or observer context | **validated** | D24 | Parts persistence | Validated: Data Part schemas defined with Zod (I17), context builders read scalars not parts (I19), structured user input round-trip tested. Full UI wiring deferred to 4b. | ## Decisions @@ -153,9 +153,9 @@ The architecture (layered: db → core → adapters): | I14 | Project-scoped API routes | Slice 3d (routing) | app.test.ts | D9 | | I15 | Route loader hydration | Slice 3d (routing) | manual (outer loop) | D9 | | I16 | Schema validation on agent tool output | Slice 4 (scope interview) | interview.test.ts | D2, A13 | -| I17 | Data Part schema validation | Slice 4a (parts persistence) | parts.test.ts | D24 | -| I18 | Parts round-trip fidelity | Slice 4a (parts persistence) | parts.test.ts | D23 | -| I19 | Context builder equivalence | Slice 4a (parts persistence) | context.test.ts | D25 | +| I17 | Data Part schema validation | Slice 4a (parts persistence) | parts.test.ts (7 tests) | D24 | +| I18 | Parts round-trip fidelity | Slice 4a (parts persistence) | parts.test.ts (8 tests), core.test.ts | D23 | +| I19 | Context builder equivalence | Slice 4a (parts persistence) | context.test.ts (7 tests) | D25 | ## Lexicon @@ -324,8 +324,10 @@ This projection difference is a deliberate design choice, not an implementation | sse-adapter.test.ts | 18 | I1, I3, I7 | | db.test.ts | 24 | I5, I6, I9, I10, I11 | | app.test.ts | 17 | I2, I3, I6, I7, I13, I14 | -| core.test.ts | 15 | I12, I13 | +| core.test.ts | 16 | I12, I13, I18 | | interview.test.ts | 16 | I16 | +| parts.test.ts | 17 | I17, I18 | +| context.test.ts | 7 | I19 | ## Acceptance Criteria (exit conditions) diff --git a/src/server/context.test.ts b/src/server/context.test.ts new file mode 100644 index 00000000..18b03fa8 --- /dev/null +++ b/src/server/context.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; + +import { buildInterviewerContext, buildObserverContext } from './context.js'; +import { formatHistory, type TurnWithOptions } from './core.js'; +import type { Turn } from './db.js'; + +// --- Interviewer context equivalence (I19) --- + +describe('interviewer-context-equivalence', () => { + it('returns prompt as-is when no turns — matches formatHistory', () => { + const result = buildInterviewerContext([], 'hello'); + const expected = formatHistory([], 'hello'); + expect(result).toBe(expected); + }); + + it('formats turns into conversation history — matches formatHistory', () => { + const turns = [ + { + id: 1, + project_id: 1, + parent_turn_id: null, + phase: 'scope' as const, + question: 'What is the project about?', + answer: 'A chat app', + why: null, + impact: null, + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + }, + ] satisfies TurnWithOptions[]; + + const result = buildInterviewerContext(turns, 'next question'); + const expected = formatHistory(turns, 'next question'); + expect(result).toBe(expected); + }); + + it('includes grounding, impact, and options — matches formatHistory', () => { + const turns: TurnWithOptions[] = [ + { + id: 1, + project_id: 1, + parent_turn_id: null, + phase: 'scope', + question: 'What is the primary goal?', + answer: 'Build a new product', + why: 'Shapes downstream decisions.', + impact: 'high', + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + options: [ + { content: 'Build a new product', is_recommended: false, is_selected: true }, + { content: 'Improve existing', is_recommended: true, is_selected: false }, + ], + }, + ]; + + const result = buildInterviewerContext(turns, 'next'); + const expected = formatHistory(turns, 'next'); + expect(result).toBe(expected); + }); + + it('handles multi-turn history — matches formatHistory', () => { + const turns: TurnWithOptions[] = [ + { + id: 1, + project_id: 1, + parent_turn_id: null, + phase: 'scope', + question: 'Q1', + answer: 'A1', + why: 'W1', + impact: 'high', + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + }, + { + id: 2, + project_id: 1, + parent_turn_id: 1, + phase: 'scope', + question: 'Q2', + answer: 'A2', + why: null, + impact: null, + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-02', + }, + ]; + + const result = buildInterviewerContext(turns, 'Q3?'); + const expected = formatHistory(turns, 'Q3?'); + expect(result).toBe(expected); + }); +}); + +// --- Observer context projection --- + +describe('observer-context-projection', () => { + it('includes current turn question and answer', () => { + const turn: Turn = { + id: 5, + project_id: 1, + parent_turn_id: 4, + phase: 'scope', + question: 'What is the target audience?', + answer: 'Developers building APIs', + why: 'Audience shapes feature priorities.', + impact: 'high', + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + }; + + const result = buildObserverContext({ + turn, + activePathSummary: '', + entities: { decisions: [], assumptions: [] }, + }); + + expect(result).toContain('What is the target audience?'); + expect(result).toContain('Developers building APIs'); + }); + + it('includes existing entity graph', () => { + const turn: Turn = { + id: 5, + project_id: 1, + parent_turn_id: 4, + phase: 'scope', + question: 'Q5', + answer: 'A5', + why: null, + impact: null, + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + }; + + const result = buildObserverContext({ + turn, + activePathSummary: 'Turn 1: goal defined. Turn 2: audience chosen.', + entities: { + decisions: [{ id: 1, content: 'Use TypeScript' }], + assumptions: [{ id: 1, content: 'Team knows TS' }], + }, + }); + + expect(result).toContain('Use TypeScript'); + expect(result).toContain('Team knows TS'); + }); + + it('omits full conversational history padding', () => { + const turn: Turn = { + id: 5, + project_id: 1, + parent_turn_id: 4, + phase: 'scope', + question: 'Q5', + answer: 'A5', + why: null, + impact: null, + is_resolution: false, + user_parts: null, + assistant_parts: null, + created_at: '2026-01-01', + }; + + const result = buildObserverContext({ + turn, + activePathSummary: 'Turn 1: goal. Turn 2: audience.', + entities: { decisions: [], assumptions: [] }, + }); + + // Should NOT contain the full Q&A pairs from earlier turns + expect(result).not.toContain('Previous conversation:'); + }); +}); diff --git a/src/server/context.ts b/src/server/context.ts new file mode 100644 index 00000000..68289068 --- /dev/null +++ b/src/server/context.ts @@ -0,0 +1,76 @@ +import type { TurnWithOptions } from './core.js'; +import type { Turn } from './db.js'; + +/** + * Build interviewer context from active-path turns. + * Drop-in replacement for formatHistory() — same output, typed interface. + * Reads from domain model (turn scalars + options), NOT from persisted parts. + */ +export function buildInterviewerContext(turns: TurnWithOptions[], currentPrompt: string): string { + if (turns.length === 0) return currentPrompt; + const lines: string[] = []; + for (const turn of turns) { + if (turn.question) { + let questionLine = `Question: ${turn.question}`; + if (turn.why) questionLine += `\n Why it matters: ${turn.why}`; + if (turn.impact) questionLine += `\n Impact: ${turn.impact}`; + if (turn.options?.length) { + const optionList = turn.options + .map((o, i) => { + const rec = o.is_recommended ? ' (recommended)' : ''; + const sel = o.is_selected ? ' [selected]' : ''; + return ` ${i + 1}. ${o.content}${rec}${sel}`; + }) + .join('\n'); + questionLine += `\n Options:\n${optionList}`; + } + lines.push(questionLine); + } + if (turn.answer) lines.push(`Answer: ${turn.answer}`); + } + if (lines.length === 0) return currentPrompt; + return `Previous conversation:\n${lines.join('\n')}\n\n---\nUser: ${currentPrompt}`; +} + +export interface ObserverContextInput { + turn: Turn; + activePathSummary: string; + entities: { + decisions: Array<{ id: number; content: string }>; + assumptions: Array<{ id: number; content: string }>; + }; +} + +/** + * Build observer context optimized for entity extraction. + * Provides the current turn's Q&A plus existing entity graph — NOT full + * conversational history. This makes each extraction incremental: + * "given what we already know, what did *this turn* add?" + */ +export function buildObserverContext(input: ObserverContextInput): string { + const sections: string[] = []; + + if (input.entities.decisions.length > 0 || input.entities.assumptions.length > 0) { + const entityLines: string[] = ['Existing entities:']; + for (const d of input.entities.decisions) { + entityLines.push(` Decision #${d.id}: ${d.content}`); + } + for (const a of input.entities.assumptions) { + entityLines.push(` Assumption #${a.id}: ${a.content}`); + } + sections.push(entityLines.join('\n')); + } + + if (input.activePathSummary) { + sections.push(`Interview summary:\n${input.activePathSummary}`); + } + + const turnLines = [`Current turn #${input.turn.id}:`]; + if (input.turn.question) turnLines.push(` Question: ${input.turn.question}`); + if (input.turn.why) turnLines.push(` Why: ${input.turn.why}`); + if (input.turn.impact) turnLines.push(` Impact: ${input.turn.impact}`); + if (input.turn.answer) turnLines.push(` Answer: ${input.turn.answer}`); + sections.push(turnLines.join('\n')); + + return sections.join('\n\n'); +} diff --git a/src/server/core.test.ts b/src/server/core.test.ts index 7602138e..9adc131d 100644 --- a/src/server/core.test.ts +++ b/src/server/core.test.ts @@ -16,7 +16,7 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ })); const { conductTurn, extractPrompt, formatHistory } = await import('./core.js'); -const { createDb, getOrCreateProject, getActivePath } = await import('./db.js'); +const { createDb, getOrCreateProject, getActivePath, getTurn } = await import('./db.js'); let db: DB; @@ -362,4 +362,43 @@ describe('conductTurn', () => { expect(turns).toHaveLength(2); expect(turns[1].parent_turn_id).toBe(turns[0].id); }); + + it('persists assistant_parts after stream finish', async () => { + mockQuery.mockReturnValue( + makeMockStream([ + { type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' } } }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'My answer.' }, + }, + }, + { type: 'stream_event', event: { type: 'message_stop' } }, + ]), + ); + + const project = getOrCreateProject(db); + const events: any[] = []; + for await (const event of conductTurn(db, project.id, 'test')) { + events.push(event); + } + + const turnCreated = events.find((e) => e.type === 'turn-created'); + const savedTurn = getTurn(db, turnCreated.turn.id); + expect(savedTurn?.assistant_parts).not.toBeNull(); + + const parts = JSON.parse(savedTurn!.assistant_parts!); + expect(parts.some((p: any) => p.type === 'reasoning')).toBe(true); + expect(parts.some((p: any) => p.type === 'text')).toBe(true); + }); }); diff --git a/src/server/core.ts b/src/server/core.ts index ae823d98..5867d18d 100644 --- a/src/server/core.ts +++ b/src/server/core.ts @@ -1,5 +1,6 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; +import { buildInterviewerContext } from './context.js'; import { getProject, getActivePath, @@ -15,6 +16,7 @@ import { type Project, } from './db.js'; import { getSystemPrompt, createInterviewMcpServer } from './interview.js'; +import { assembleAssistantParts, serializeParts } from './parts.js'; /** Domain events yielded by conductTurn(). Transport-agnostic. */ export type DomainEvent = @@ -47,31 +49,12 @@ export interface TurnWithOptions extends Turn { options?: Array<{ content: string; is_recommended: boolean; is_selected: boolean }>; } -/** Format conversation history from active-path turns for multi-turn context. */ +/** + * Format conversation history from active-path turns for multi-turn context. + * @deprecated Use buildInterviewerContext from context.ts directly. + */ export function formatHistory(turns: TurnWithOptions[], currentPrompt: string): string { - if (turns.length === 0) return currentPrompt; - const lines: string[] = []; - for (const turn of turns) { - if (turn.question) { - let questionLine = `Question: ${turn.question}`; - if (turn.why) questionLine += `\n Why it matters: ${turn.why}`; - if (turn.impact) questionLine += `\n Impact: ${turn.impact}`; - if (turn.options?.length) { - const optionList = turn.options - .map((o, i) => { - const rec = o.is_recommended ? ' (recommended)' : ''; - const sel = o.is_selected ? ' [selected]' : ''; - return ` ${i + 1}. ${o.content}${rec}${sel}`; - }) - .join('\n'); - questionLine += `\n Options:\n${optionList}`; - } - lines.push(questionLine); - } - if (turn.answer) lines.push(`Answer: ${turn.answer}`); - } - if (lines.length === 0) return currentPrompt; - return `Previous conversation:\n${lines.join('\n')}\n\n---\nUser: ${currentPrompt}`; + return buildInterviewerContext(turns, currentPrompt); } /** SDK stream event shapes we consume */ @@ -113,11 +96,16 @@ export async function* conductTurn( yield { type: 'turn-created', turn }; - const fullPrompt = formatHistory(activePath, userMessage); + const fullPrompt = buildInterviewerContext(activePath, userMessage); let assistantText = ''; let errored = false; + const collectedEvents: DomainEvent[] = []; + + function emit(ev: DomainEvent): DomainEvent { + collectedEvents.push(ev); + return ev; + } - // Create per-turn MCP server — tool handler persists structured data via closure const interviewServer = createInterviewMcpServer(db, turn.id); try { @@ -140,14 +128,14 @@ export async function* conductTurn( switch (event.type) { case 'message_start': - yield { type: 'stream-start', messageId: event.message!.id }; + yield emit({ type: 'stream-start', messageId: event.message!.id }); break; case 'content_block_start': { const block = event.content_block!; if (block.type === 'tool_use') { toolUseBlocks.set(event.index!, { toolName: block.name!, toolCallId: block.id! }); - yield { type: 'tool-call-start', toolName: block.name!, toolCallId: block.id! }; + yield emit({ type: 'tool-call-start', toolName: block.name!, toolCallId: block.id! }); } break; } @@ -155,17 +143,17 @@ export async function* conductTurn( case 'content_block_delta': { const delta = event.delta!; if (delta.type === 'thinking_delta' && delta.thinking) { - yield { type: 'thinking', delta: delta.thinking }; + yield emit({ type: 'thinking', delta: delta.thinking }); } else if (delta.type === 'text_delta' && delta.text) { assistantText += delta.text; - yield { type: 'text-delta', delta: delta.text }; + yield emit({ type: 'text-delta', delta: delta.text }); } else if (delta.type === 'input_json_delta' && delta.partial_json) { const toolBlock = toolUseBlocks.get(event.index!); - yield { + yield emit({ type: 'tool-call-delta', toolCallId: toolBlock?.toolCallId ?? '', delta: delta.partial_json, - }; + }); } break; } @@ -173,18 +161,18 @@ export async function* conductTurn( case 'content_block_stop': { const toolBlock = toolUseBlocks.get(event.index!); if (toolBlock) { - yield { + yield emit({ type: 'tool-call-end', toolCallId: toolBlock.toolCallId, toolName: toolBlock.toolName, - }; + }); toolUseBlocks.delete(event.index!); } break; } case 'message_stop': - yield { type: 'stream-end' }; + yield emit({ type: 'stream-end' }); break; } } @@ -195,11 +183,16 @@ export async function* conductTurn( } if (!errored) { - // Only persist raw text if no structured question was set via MCP tool handler const currentTurn = getTurn(db, turn.id); - if (assistantText && (!currentTurn?.question || currentTurn.question === '')) { - updateTurn(db, turn.id, { question: assistantText }); - } + const parts = assembleAssistantParts(collectedEvents); + + updateTurn(db, turn.id, { + ...(assistantText && (!currentTurn?.question || currentTurn.question === '') + ? { question: assistantText } + : {}), + ...(parts.length > 0 ? { assistant_parts: serializeParts(parts) } : {}), + }); + advanceHead(db, projectId, turn.id); } } diff --git a/src/server/db.ts b/src/server/db.ts index fa90f222..5b59dd63 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -85,6 +85,8 @@ export interface UpdateTurnInput { answer?: string; why?: string | null; impact?: Impact | null; + user_parts?: string | null; + assistant_parts?: string | null; } export function updateTurn(db: DB, turnId: number, updates: UpdateTurnInput): void { @@ -92,7 +94,9 @@ export function updateTurn(db: DB, turnId: number, updates: UpdateTurnInput): vo updates.question === undefined && updates.answer === undefined && updates.why === undefined && - updates.impact === undefined + updates.impact === undefined && + updates.user_parts === undefined && + updates.assistant_parts === undefined ) return; const values: Record = {}; @@ -100,6 +104,8 @@ export function updateTurn(db: DB, turnId: number, updates: UpdateTurnInput): vo if (updates.answer !== undefined) values.answer = updates.answer; if (updates.why !== undefined) values.why = updates.why; if (updates.impact !== undefined) values.impact = updates.impact; + if (updates.user_parts !== undefined) values.user_parts = updates.user_parts; + if (updates.assistant_parts !== undefined) values.assistant_parts = updates.assistant_parts; db.update(schema.turn).set(values).where(eq(schema.turn.id, turnId)).run(); } diff --git a/src/server/parts.test.ts b/src/server/parts.test.ts new file mode 100644 index 00000000..ea1dfe9a --- /dev/null +++ b/src/server/parts.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import type { DomainEvent } from './core.js'; +import { createDb, type DB } from './db.js'; +import { + assembleAssistantParts, + serializeParts, + deserializeAssistantParts, + dataOptionSelectionSchema, + dataConfirmationSchema, + type AssistantPart, +} from './parts.js'; + +// --- Schema migration --- + +let db: DB; + +beforeEach(() => { + db = createDb(); +}); + +afterEach(() => { + db.$client.close(); +}); + +describe('migration-adds-parts-columns', () => { + it('turn table has user_parts and assistant_parts columns', () => { + const columns = db.$client.prepare("PRAGMA table_info('turn')").all() as Array<{ name: string }>; + const names = columns.map((c) => c.name); + expect(names).toContain('user_parts'); + expect(names).toContain('assistant_parts'); + }); +}); + +// --- Parts assembly --- + +describe('assemble-assistant-parts', () => { + it('assembles reasoning, text, and tool-call parts from DomainEvents', () => { + const events: DomainEvent[] = [ + { type: 'stream-start', messageId: 'msg-1' }, + { type: 'thinking', delta: 'Let me ' }, + { type: 'thinking', delta: 'think about this.' }, + { type: 'text-delta', delta: 'Here is ' }, + { type: 'text-delta', delta: 'my answer.' }, + { type: 'tool-call-start', toolName: 'ask_question', toolCallId: 'toolu_01' }, + { type: 'tool-call-delta', toolCallId: 'toolu_01', delta: '{"question":' }, + { type: 'tool-call-delta', toolCallId: 'toolu_01', delta: '"What?"}' }, + { type: 'tool-call-end', toolCallId: 'toolu_01', toolName: 'ask_question' }, + { type: 'stream-end' }, + ]; + + const parts = assembleAssistantParts(events); + + expect(parts).toHaveLength(3); + expect(parts[0]).toEqual({ type: 'reasoning', text: 'Let me think about this.' }); + expect(parts[1]).toEqual({ type: 'text', text: 'Here is my answer.' }); + expect(parts[2]).toEqual({ + type: 'tool-invocation', + toolCallId: 'toolu_01', + toolName: 'ask_question', + args: { question: 'What?' }, + state: 'result', + }); + }); + + it('concatenates consecutive thinking deltas into one reasoning part', () => { + const events: DomainEvent[] = [ + { type: 'stream-start', messageId: 'msg-1' }, + { type: 'thinking', delta: 'First ' }, + { type: 'thinking', delta: 'second ' }, + { type: 'thinking', delta: 'third.' }, + { type: 'stream-end' }, + ]; + + const parts = assembleAssistantParts(events); + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ type: 'reasoning', text: 'First second third.' }); + }); + + it('handles empty event stream', () => { + const parts = assembleAssistantParts([]); + expect(parts).toEqual([]); + }); + + it('handles stream with only control events', () => { + const events: DomainEvent[] = [{ type: 'stream-start', messageId: 'msg-1' }, { type: 'stream-end' }]; + + const parts = assembleAssistantParts(events); + expect(parts).toEqual([]); + }); + + it('handles interleaved reasoning and text blocks', () => { + const events: DomainEvent[] = [ + { type: 'stream-start', messageId: 'msg-1' }, + { type: 'thinking', delta: 'Hmm...' }, + { type: 'text-delta', delta: 'Answer part 1. ' }, + { type: 'text-delta', delta: 'Answer part 2.' }, + { type: 'stream-end' }, + ]; + + const parts = assembleAssistantParts(events); + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ type: 'reasoning', text: 'Hmm...' }); + expect(parts[1]).toEqual({ type: 'text', text: 'Answer part 1. Answer part 2.' }); + }); + + it('handles multiple tool calls', () => { + const events: DomainEvent[] = [ + { type: 'stream-start', messageId: 'msg-1' }, + { type: 'tool-call-start', toolName: 'tool_a', toolCallId: 'tc-1' }, + { type: 'tool-call-delta', toolCallId: 'tc-1', delta: '{"a":1}' }, + { type: 'tool-call-end', toolCallId: 'tc-1', toolName: 'tool_a' }, + { type: 'tool-call-start', toolName: 'tool_b', toolCallId: 'tc-2' }, + { type: 'tool-call-delta', toolCallId: 'tc-2', delta: '{"b":2}' }, + { type: 'tool-call-end', toolCallId: 'tc-2', toolName: 'tool_b' }, + { type: 'stream-end' }, + ]; + + const parts = assembleAssistantParts(events); + expect(parts).toHaveLength(2); + expect(parts[0]).toMatchObject({ type: 'tool-invocation', toolName: 'tool_a', args: { a: 1 } }); + expect(parts[1]).toMatchObject({ type: 'tool-invocation', toolName: 'tool_b', args: { b: 2 } }); + }); +}); + +// --- Data Part schemas --- + +describe('data-part-schemas', () => { + it('validates correct data-option-selection', () => { + const valid = { turnId: 1, selectedOptionId: 2, rationale: 'Best fit' }; + expect(dataOptionSelectionSchema.parse(valid)).toEqual(valid); + }); + + it('validates data-option-selection without optional rationale', () => { + const valid = { turnId: 1, selectedOptionId: 0 }; + expect(dataOptionSelectionSchema.parse(valid)).toEqual(valid); + }); + + it('rejects data-option-selection with missing turnId', () => { + expect(() => dataOptionSelectionSchema.parse({ selectedOptionId: 0 })).toThrow(); + }); + + it('rejects data-option-selection with string turnId', () => { + expect(() => dataOptionSelectionSchema.parse({ turnId: 'abc', selectedOptionId: 0 })).toThrow(); + }); + + it('validates correct data-confirmation', () => { + const valid = { turnId: 5, confirmed: true }; + expect(dataConfirmationSchema.parse(valid)).toEqual(valid); + }); + + it('rejects data-confirmation with missing confirmed', () => { + expect(() => dataConfirmationSchema.parse({ turnId: 5 })).toThrow(); + }); + + it('rejects data-confirmation with string confirmed', () => { + expect(() => dataConfirmationSchema.parse({ turnId: 5, confirmed: 'yes' })).toThrow(); + }); +}); + +// --- Round-trip oracle --- + +describe('parts-round-trip', () => { + it('assistant parts survive JSON serialization round-trip', () => { + const original: AssistantPart[] = [ + { type: 'reasoning', text: 'Let me think about this carefully.' }, + { type: 'text', text: 'Here is my structured response.' }, + { + type: 'tool-invocation', + toolCallId: 'toolu_01', + toolName: 'ask_question', + args: { question: 'What?', options: [{ content: 'A' }] }, + state: 'result', + }, + ]; + + const json = serializeParts(original); + const restored = deserializeAssistantParts(json); + expect(restored).toEqual(original); + }); + + it('empty parts array round-trips', () => { + const json = serializeParts([]); + const restored = deserializeAssistantParts(json); + expect(restored).toEqual([]); + }); +}); diff --git a/src/server/parts.ts b/src/server/parts.ts new file mode 100644 index 00000000..404e9049 --- /dev/null +++ b/src/server/parts.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; + +import type { DomainEvent } from './core.js'; + +// --- Assistant part types (assembled from DomainEvents) --- + +export type ReasoningPart = { type: 'reasoning'; text: string }; +export type TextPart = { type: 'text'; text: string }; +export type ToolInvocationPart = { + type: 'tool-invocation'; + toolCallId: string; + toolName: string; + args: Record; + state: 'result'; +}; + +export type AssistantPart = ReasoningPart | TextPart | ToolInvocationPart; + +// --- Data Part schemas (Zod v4) --- + +export const dataOptionSelectionSchema = z.object({ + turnId: z.number(), + selectedOptionId: z.number(), + rationale: z.string().optional(), +}); + +export const dataConfirmationSchema = z.object({ + turnId: z.number(), + confirmed: z.boolean(), +}); + +export type DataOptionSelection = z.infer; +export type DataConfirmation = z.infer; + +// --- User part types --- + +export type DataOptionSelectionPart = { type: 'data-option-selection'; data: DataOptionSelection }; +export type DataConfirmationPart = { type: 'data-confirmation'; data: DataConfirmation }; +export type UserPart = TextPart | DataOptionSelectionPart | DataConfirmationPart; + +// --- Assembler --- + +/** + * Accumulate DomainEvents into assistant parts[]. + * Consecutive deltas of the same type are merged into a single part. + * Tool call args JSON fragments are concatenated and parsed on tool-call-end. + */ +export function assembleAssistantParts(events: DomainEvent[]): AssistantPart[] { + const parts: AssistantPart[] = []; + let currentType: 'reasoning' | 'text' | null = null; + let currentText = ''; + const toolArgs = new Map(); + + function flushText() { + if (currentType && currentText) { + parts.push( + currentType === 'reasoning' + ? { type: 'reasoning', text: currentText } + : { type: 'text', text: currentText }, + ); + } + currentType = null; + currentText = ''; + } + + for (const event of events) { + switch (event.type) { + case 'thinking': { + if (currentType !== 'reasoning') flushText(); + currentType = 'reasoning'; + currentText += event.delta; + break; + } + case 'text-delta': { + if (currentType !== 'text') flushText(); + currentType = 'text'; + currentText += event.delta; + break; + } + case 'tool-call-start': { + flushText(); + toolArgs.set(event.toolCallId, { toolName: event.toolName, json: '' }); + break; + } + case 'tool-call-delta': { + const entry = toolArgs.get(event.toolCallId); + if (entry) entry.json += event.delta; + break; + } + case 'tool-call-end': { + const entry = toolArgs.get(event.toolCallId); + if (entry) { + let args: Record = {}; + try { + args = JSON.parse(entry.json) as Record; + } catch { + args = { _raw: entry.json }; + } + parts.push({ + type: 'tool-invocation', + toolCallId: event.toolCallId, + toolName: event.toolName, + args, + state: 'result', + }); + toolArgs.delete(event.toolCallId); + } + break; + } + case 'stream-end': { + flushText(); + break; + } + } + } + + flushText(); + return parts; +} + +/** Serialize parts to JSON for persistence. */ +export function serializeParts(parts: AssistantPart[] | UserPart[]): string { + return JSON.stringify(parts); +} + +/** Deserialize parts from persisted JSON. */ +export function deserializeAssistantParts(json: string): AssistantPart[] { + return JSON.parse(json) as AssistantPart[]; +} + +export function deserializeUserParts(json: string): UserPart[] { + return JSON.parse(json) as UserPart[]; +} diff --git a/src/server/schema.ts b/src/server/schema.ts index 3162c7fd..1b697e28 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -27,6 +27,8 @@ export const turn = sqliteTable('turn', { impact: text({ enum: ['high', 'medium', 'low'] }), answer: text(), is_resolution: integer({ mode: 'boolean' }).notNull().default(false), + user_parts: text(), + assistant_parts: text(), created_at: text() .notNull() .default(sql`(datetime('now'))`),