From 190237a8635c4fcc19882bc9dea79444b03af17a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 2 Apr 2026 13:03:28 +0200 Subject: [PATCH 1/2] feat: parts-based persistence + context builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema migration adds user_parts and assistant_parts JSON columns to turn table. Parts assembler converts DomainEvents into typed UIMessage parts (reasoning, text, tool-invocation) persisted on stream finish. Data Part schemas (data-option-selection, data-confirmation) defined with Zod validation. Context builders (buildInterviewerContext, buildObserverContext) replace monolithic formatHistory — different consumers get different projections from the same turn tree. Establishes I17 (Data Part schema validation), I18 (parts round-trip fidelity), I19 (context builder equivalence). 24 new tests (112 total). Made-with: Cursor --- drizzle/0002_bright_master_mold.sql | 2 + drizzle/meta/0002_snapshot.json | 841 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + memory/PLAN.md | 4 +- src/server/context.test.ts | 187 +++++++ src/server/context.ts | 76 +++ src/server/core.test.ts | 41 +- src/server/core.ts | 71 ++- src/server/db.ts | 8 +- src/server/parts.test.ts | 187 +++++++ src/server/parts.ts | 133 +++++ src/server/schema.ts | 2 + 12 files changed, 1516 insertions(+), 43 deletions(-) create mode 100644 drizzle/0002_bright_master_mold.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/server/context.test.ts create mode 100644 src/server/context.ts create mode 100644 src/server/parts.test.ts create mode 100644 src/server/parts.ts 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..b1346c60 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. `in-progress` - 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/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'))`), From 26ec729bba268d1572075951fc874366fad9a91d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 2 Apr 2026 13:04:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?spec/plan:=20traceability=20for=20slice=204?= =?UTF-8?q?a=20=E2=80=94=20A22/A23=20validated,=20I17-I19=20established?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark slice 4a done in PLAN.md. Graduate A22 and A23 to validated in SPEC.md. Update invariant table with test file references and current coverage section with new test files (parts.test.ts, context.test.ts). Made-with: Cursor --- memory/PLAN.md | 2 +- memory/SPEC.md | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index b1346c60..98d6a870 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -100,7 +100,7 @@ - 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** `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. `in-progress` +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 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)