From bd6d385921c0f5604d942c04981a67edd1cfe9a4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 13:27:27 +0200 Subject: [PATCH 1/7] chore: restore lint baseline and fix deprecated APIs --- .oxlintrc.json | 10 +++++++ .../components/ai-elements/prompt-input.tsx | 8 +++--- src/server/observer.test.ts | 28 +++++++++++-------- src/server/observer.ts | 8 +++--- src/shared/chat.ts | 16 +++++------ 5 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 .oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..978a2de0 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "typescript/no-deprecated": "error" + } +} diff --git a/src/client/components/ai-elements/prompt-input.tsx b/src/client/components/ai-elements/prompt-input.tsx index cdd19574..58b64ab1 100644 --- a/src/client/components/ai-elements/prompt-input.tsx +++ b/src/client/components/ai-elements/prompt-input.tsx @@ -8,13 +8,13 @@ import type { ChangeEventHandler, ClipboardEventHandler, ComponentProps, - FormEvent, - FormEventHandler, HTMLAttributes, KeyboardEventHandler, PropsWithChildren, ReactNode, RefObject, + SubmitEvent, + SubmitEventHandler, } from 'react'; import { Children, @@ -442,7 +442,7 @@ export type PromptInputProps = Omit, 'onSubmit' // bytes maxFileSize?: number; onError?: (err: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void; - onSubmit: (message: PromptInputMessage, event: FormEvent) => void | Promise; + onSubmit: (message: PromptInputMessage, event: SubmitEvent) => void | Promise; }; export const PromptInput = ({ @@ -755,7 +755,7 @@ export const PromptInput = ({ [referencedSources, clearReferencedSources], ); - const handleSubmit: FormEventHandler = useCallback( + const handleSubmit: SubmitEventHandler = useCallback( async (event) => { event.preventDefault(); diff --git a/src/server/observer.test.ts b/src/server/observer.test.ts index 557644fb..e5d6a0c4 100644 --- a/src/server/observer.test.ts +++ b/src/server/observer.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DB } from './db.js'; -const { mockGenerateObject, mockAnthropic } = vi.hoisted(() => ({ - mockGenerateObject: vi.fn(), +const { mockGenerateText, mockAnthropic } = vi.hoisted(() => ({ + mockGenerateText: vi.fn(), mockAnthropic: vi.fn(() => 'mock-model'), })); @@ -15,17 +15,17 @@ vi.mock('ai', async () => { const actual = await vi.importActual('ai'); return { ...actual, - generateObject: mockGenerateObject, + generateText: mockGenerateText, }; }); -const { runObserver, observerOutputSchema } = await import('./observer.js'); +const { runObserver } = await import('./observer.js'); const { createDb, createProject, createTurn, getEntitiesForProject } = await import('./db.js'); let db: DB; beforeEach(() => { - mockGenerateObject.mockReset(); + mockGenerateText.mockReset(); db = createDb(); }); @@ -35,8 +35,8 @@ afterEach(() => { describe('runObserver', () => { it('persists extracted decisions and assumptions and returns their ids', async () => { - mockGenerateObject.mockResolvedValue({ - object: { + mockGenerateText.mockResolvedValue({ + output: { decisions: [ { content: 'Use SQLite', @@ -61,9 +61,9 @@ describe('runObserver', () => { expect(entities.assumptions[0].content).toBe('Single-user tool'); }); - it('calls generateObject with the typed schema and turn context', async () => { - mockGenerateObject.mockResolvedValue({ - object: { + it('calls generateText with structured output and turn context', async () => { + mockGenerateText.mockResolvedValue({ + output: { decisions: [], assumptions: [], }, @@ -79,9 +79,13 @@ describe('runObserver', () => { await runObserver(db, turn, project.id); expect(mockAnthropic).toHaveBeenCalled(); - expect(mockGenerateObject).toHaveBeenCalledWith( + expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - schema: observerOutputSchema, + output: expect.objectContaining({ + name: 'object', + parseCompleteOutput: expect.any(Function), + parsePartialOutput: expect.any(Function), + }), prompt: expect.stringContaining('What database?'), }), ); diff --git a/src/server/observer.ts b/src/server/observer.ts index 73989ba0..471c8659 100644 --- a/src/server/observer.ts +++ b/src/server/observer.ts @@ -1,5 +1,5 @@ import { anthropic } from '@ai-sdk/anthropic'; -import { generateObject } from 'ai'; +import { generateText, Output } from 'ai'; import * as z from 'zod/v4'; import { buildObserverContext } from './context.js'; @@ -69,15 +69,15 @@ export async function runObserver( entities, }); - const result = await generateObject({ + const result = await generateText({ model: anthropic(process.env.OBSERVER_MODEL || 'claude-haiku-4-5-20251001'), maxOutputTokens: 2048, system: OBSERVER_SYSTEM_PROMPT, prompt: context, - schema: observerOutputSchema, + output: Output.object({ schema: observerOutputSchema }), }); - const parsed = result.object; + const parsed = result.output; // Persist entities in a transaction-like sequence const createdDecisionIds: number[] = []; diff --git a/src/shared/chat.ts b/src/shared/chat.ts index af350a32..1dc59c7f 100644 --- a/src/shared/chat.ts +++ b/src/shared/chat.ts @@ -108,7 +108,7 @@ const textPartSchema = z text: z.string(), state: z.enum(['streaming', 'done']).optional(), }) - .passthrough(); + .loose(); const reasoningPartSchema = z .object({ @@ -116,13 +116,13 @@ const reasoningPartSchema = z text: z.string(), state: z.enum(['streaming', 'done']).optional(), }) - .passthrough(); + .loose(); const stepStartPartSchema = z .object({ type: z.literal('step-start'), }) - .passthrough(); + .loose(); const observerResultPartSchema = z .object({ @@ -130,7 +130,7 @@ const observerResultPartSchema = z id: z.string().optional(), data: observerResultSchema, }) - .passthrough(); + .loose(); const phaseSummaryPartSchema = z .object({ @@ -138,7 +138,7 @@ const phaseSummaryPartSchema = z id: z.string().optional(), data: dataPhaseSummarySchema, }) - .passthrough(); + .loose(); const optionSelectionPartSchema = z .object({ @@ -146,7 +146,7 @@ const optionSelectionPartSchema = z id: z.string().optional(), data: dataOptionSelectionSchema, }) - .passthrough(); + .loose(); const confirmationPartSchema = z .object({ @@ -154,7 +154,7 @@ const confirmationPartSchema = z id: z.string().optional(), data: dataConfirmationSchema, }) - .passthrough(); + .loose(); const approvalRequestedSchema = z.object({ id: z.string(), @@ -173,7 +173,7 @@ const askQuestionToolBaseSchema = z title: z.string().optional(), providerExecuted: z.boolean().optional(), }) - .passthrough(); + .loose(); const askQuestionToolPartSchema = z.union([ askQuestionToolBaseSchema.extend({ From 2f18f6f646301bf95cef6102b5e7a46173dd6f11 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 13:35:40 +0200 Subject: [PATCH 2/7] chore: expand tooling coverage and fix config warnings --- drizzle.config.ts | 12 +- package.json | 171 +++++++++--------- .../capabilities/markdown-rendering.test.tsx | 31 ++-- .../capabilities/rich-markdown-rendering.tsx | 6 +- tsconfig.tools.json | 4 + vite.config.ts | 31 ++-- 6 files changed, 136 insertions(+), 119 deletions(-) create mode 100644 tsconfig.tools.json diff --git a/drizzle.config.ts b/drizzle.config.ts index 4b23ea57..76a0c63e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - out: './drizzle', - schema: './src/server/schema.ts', - dialect: 'sqlite', - dbCredentials: { - url: process.env.BRUNCH_DB || './brunch.db', - }, + out: './drizzle', + schema: './src/server/schema.ts', + dialect: 'sqlite', + dbCredentials: { + url: process.env.BRUNCH_DB || './brunch.db', + }, }); diff --git a/package.json b/package.json index a7e774a6..c417b577 100644 --- a/package.json +++ b/package.json @@ -1,87 +1,88 @@ { - "name": "@hashintel/brunch", - "private": true, - "description": "AI chat interface built on HASH", - "homepage": "https://github.com/hashintel/brunch#readme", - "bugs": { - "url": "https://github.com/hashintel/brunch/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/hashintel/brunch.git" - }, - "license": "(MIT OR Apache-2.0)", - "type": "module", - "scripts": { - "build": "vite build", - "check": "npm run fmt:check && npm run lint", - "dev": "agent-tail run 'vite: lsof -ti:5173 | xargs kill -9 2>/dev/null; vite' 'api: lsof -ti:3000 | xargs kill -9 2>/dev/null; npx tsx --env-file=.env --watch src/server/index.ts'", - "fix": "npm run lint:fix && npm run fmt", - "fmt": "oxfmt src/", - "fmt:check": "oxfmt --check src/", - "lint": "oxlint --type-aware --type-check src/", - "lint:fix": "oxlint --type-aware --type-check --fix src/", - "server": "npx tsx --env-file=.env src/server/index.ts", - "studio": "drizzle-kit studio", - "test": "vitest run", - "verify": "npm run check && npm run test && npm run build" - }, - "dependencies": { - "@ai-sdk/anthropic": "^3.0.66", - "@ai-sdk/react": "^3.0.145", - "@anthropic-ai/sdk": "^0.82.0", - "@fontsource-variable/geist": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.27.1", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@streamdown/cjk": "^1.0.3", - "@streamdown/code": "^1.1.1", - "@streamdown/math": "^1.0.2", - "@streamdown/mermaid": "^1.0.2", - "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-query": "^5.96.1", - "@tanstack/react-router": "^1.168.10", - "@vitejs/plugin-react": "^5.2.0", - "ai": "^6.0.143", - "better-sqlite3": "^12.8.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "drizzle-orm": "^0.45.2", - "express": "^5.2.1", - "lucide-react": "^1.7.0", - "md-pen": "^1.2.0", - "motion": "^12.38.0", - "nanoid": "^5.1.7", - "radix-ui": "^1.4.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "shiki": "^4.0.2", - "streamdown": "^2.5.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", - "tw-animate-css": "^1.4.0", - "use-stick-to-bottom": "^1.1.3", - "zod": "^4.3.6" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/better-sqlite3": "^7.6.13", - "@types/express": "^5.0.6", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/supertest": "^7.2.0", - "agent-tail": "^0.4.0", - "concurrently": "^9.2.1", - "drizzle-kit": "^0.31.10", - "happy-dom": "^20.8.9", - "oxfmt": "^0.43.0", - "oxlint": "^1.58.0", - "oxlint-tsgolint": "^0.19.0", - "shadcn": "^4.1.2", - "supertest": "^7.2.2", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vite": "^7.0.4", - "vitest": "^4.1.0" - } + "name": "@hashintel/brunch", + "private": true, + "description": "AI chat interface built on HASH", + "homepage": "https://github.com/hashintel/brunch#readme", + "bugs": { + "url": "https://github.com/hashintel/brunch/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/hashintel/brunch.git" + }, + "license": "(MIT OR Apache-2.0)", + "type": "module", + "scripts": { + "build": "vite build", + "check": "npm run fmt:check && npm run lint && npm run typecheck", + "dev": "agent-tail run 'vite: lsof -ti:5173 | xargs kill -9 2>/dev/null; vite' 'api: lsof -ti:3000 | xargs kill -9 2>/dev/null; npx tsx --env-file=.env --watch src/server/index.ts'", + "fix": "npm run lint:fix && npm run fmt", + "fmt": "oxfmt src/ vite.config.ts drizzle.config.ts", + "fmt:check": "oxfmt --check src/ vite.config.ts drizzle.config.ts", + "lint": "oxlint --type-aware --type-check src/ vite.config.ts drizzle.config.ts", + "lint:fix": "oxlint --type-aware --type-check --fix src/ vite.config.ts drizzle.config.ts", + "server": "npx tsx --env-file=.env src/server/index.ts", + "studio": "drizzle-kit studio", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.tools.json", + "verify": "npm run check && npm run test && npm run build" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.66", + "@ai-sdk/react": "^3.0.145", + "@anthropic-ai/sdk": "^0.82.0", + "@fontsource-variable/geist": "^5.2.8", + "@modelcontextprotocol/sdk": "^1.27.1", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.96.1", + "@tanstack/react-router": "^1.168.10", + "@vitejs/plugin-react": "^5.2.0", + "ai": "^6.0.143", + "better-sqlite3": "^12.8.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "lucide-react": "^1.7.0", + "md-pen": "^1.2.0", + "motion": "^12.38.0", + "nanoid": "^5.1.7", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "shiki": "^4.0.2", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "use-stick-to-bottom": "^1.1.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/supertest": "^7.2.0", + "agent-tail": "^0.4.0", + "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.10", + "happy-dom": "^20.8.9", + "oxfmt": "^0.43.0", + "oxlint": "^1.58.0", + "oxlint-tsgolint": "^0.19.0", + "shadcn": "^4.1.2", + "supertest": "^7.2.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.0.4", + "vitest": "^4.1.0" + } } diff --git a/src/client/capabilities/markdown-rendering.test.tsx b/src/client/capabilities/markdown-rendering.test.tsx index faba67f9..a8aff17e 100644 --- a/src/client/capabilities/markdown-rendering.test.tsx +++ b/src/client/capabilities/markdown-rendering.test.tsx @@ -47,22 +47,29 @@ describe('MarkdownRenderer', () => { it('keeps rich markdown on the plain first-paint path while the message is animating', async () => { const { MarkdownRenderer } = await import('./markdown-rendering.js'); const content = '```typescript\nconst answer = 42\n```'; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { container, rerender } = render({content}); + try { + const { container, rerender } = render({content}); - expect(container.querySelector('[data-rendering-mode="plain"]')?.textContent).toContain( - 'const answer = 42', - ); + expect(container.querySelector('[data-rendering-mode="plain"]')?.textContent).toContain( + 'const answer = 42', + ); - await Promise.resolve(); - expect(container.querySelector('[data-rendering-mode="rich"]')).toBeNull(); + await Promise.resolve(); + expect(container.querySelector('[data-rendering-mode="rich"]')).toBeNull(); - rerender({content}); + rerender({content}); - await waitFor(() => { - expect(container.querySelector('[data-rendering-mode="rich"]')?.textContent).toContain( - 'const answer = 42', - ); - }); + await waitFor(() => { + expect(container.querySelector('[data-rendering-mode="rich"]')?.textContent).toContain( + 'const answer = 42', + ); + }); + + expect(consoleError.mock.calls.some((call) => String(call[0]).includes('isAnimating'))).toBe(false); + } finally { + consoleError.mockRestore(); + } }); }); diff --git a/src/client/capabilities/rich-markdown-rendering.tsx b/src/client/capabilities/rich-markdown-rendering.tsx index 34139203..c5cb6bbe 100644 --- a/src/client/capabilities/rich-markdown-rendering.tsx +++ b/src/client/capabilities/rich-markdown-rendering.tsx @@ -10,7 +10,11 @@ import type { MarkdownRendererProps } from './markdown-rendering'; const markdownRenderingPlugins = { cjk, code, math, mermaid }; -export const RichMarkdownRenderer = ({ children, ...props }: MarkdownRendererProps) => ( +export const RichMarkdownRenderer = ({ + children, + isAnimating: _isAnimating, + ...props +}: MarkdownRendererProps) => (
{children} diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 00000000..46f17ec5 --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["vite.config.ts", "drizzle.config.ts"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index a7d6eaec..5218fd3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,26 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; + import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { agentTail } from 'agent-tail/vite'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [react(), tailwindcss(), agentTail()], - resolve: { - alias: { - '@': resolve(__dirname, './src/client'), - }, - }, - server: { - proxy: { - '/api': 'http://localhost:3000', - }, - }, - test: { - include: ['src/**/*.test.{js,ts,jsx,tsx}'], - }, + plugins: [react(), tailwindcss(), agentTail()], + resolve: { + alias: { + '@': resolve(__dirname, './src/client'), + }, + }, + server: { + proxy: { + '/api': 'http://localhost:3000', + }, + }, + test: { + include: ['src/**/*.test.{js,ts,jsx,tsx}'], + }, }); From 7de0d9f764a7bffa0458c9e4ff61b0722ba75b32 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 13:41:51 +0200 Subject: [PATCH 3/7] chore: trim rich markdown rendering features --- src/client/capabilities/code-highlighting.ts | 6 ++++-- src/client/capabilities/rich-code-highlighting.ts | 5 ++++- src/client/capabilities/rich-markdown-rendering.tsx | 4 +--- src/client/capability-boundaries.test.ts | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/client/capabilities/code-highlighting.ts b/src/client/capabilities/code-highlighting.ts index d3024524..57e70408 100644 --- a/src/client/capabilities/code-highlighting.ts +++ b/src/client/capabilities/code-highlighting.ts @@ -1,8 +1,10 @@ 'use client'; -import type { BundledLanguage, ThemedToken } from 'shiki'; +import type { ThemedToken } from 'shiki'; -export type CodeLanguage = BundledLanguage; +export const SUPPORTED_CODE_LANGUAGES = ['json', 'text', 'typescript'] as const; + +export type CodeLanguage = (typeof SUPPORTED_CODE_LANGUAGES)[number]; export type CodeToken = ThemedToken; export interface TokenizedCode { diff --git a/src/client/capabilities/rich-code-highlighting.ts b/src/client/capabilities/rich-code-highlighting.ts index c030b0f6..7d55842f 100644 --- a/src/client/capabilities/rich-code-highlighting.ts +++ b/src/client/capabilities/rich-code-highlighting.ts @@ -2,9 +2,11 @@ import type { BundledTheme, HighlighterGeneric } from 'shiki'; import { createHighlighter } from 'shiki'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import type { CodeLanguage, TokenizedCode } from './code-highlighting'; +const regexEngine = createJavaScriptRegexEngine({ forgiving: true }); const highlighterCache = new Map>>(); const getHighlighter = (language: CodeLanguage): Promise> => { @@ -14,9 +16,10 @@ const getHighlighter = (language: CodeLanguage): Promise>; highlighterCache.set(language, highlighterPromise); return highlighterPromise; diff --git a/src/client/capabilities/rich-markdown-rendering.tsx b/src/client/capabilities/rich-markdown-rendering.tsx index c5cb6bbe..17d580ef 100644 --- a/src/client/capabilities/rich-markdown-rendering.tsx +++ b/src/client/capabilities/rich-markdown-rendering.tsx @@ -1,14 +1,12 @@ 'use client'; import { cjk } from '@streamdown/cjk'; -import { code } from '@streamdown/code'; import { math } from '@streamdown/math'; -import { mermaid } from '@streamdown/mermaid'; import { Streamdown } from 'streamdown'; import type { MarkdownRendererProps } from './markdown-rendering'; -const markdownRenderingPlugins = { cjk, code, math, mermaid }; +const markdownRenderingPlugins = { cjk, math }; export const RichMarkdownRenderer = ({ children, diff --git a/src/client/capability-boundaries.test.ts b/src/client/capability-boundaries.test.ts index 71eca20c..245db010 100644 --- a/src/client/capability-boundaries.test.ts +++ b/src/client/capability-boundaries.test.ts @@ -20,7 +20,8 @@ describe('client capability boundaries', () => { expect(markdownCapabilitySource).toContain('export const preloadRichMarkdownRenderer'); expect(markdownCapabilitySource).not.toContain("from 'streamdown'"); expect(richMarkdownCapabilitySource).toContain("from 'streamdown'"); - expect(richMarkdownCapabilitySource).toContain("from '@streamdown/mermaid'"); + expect(richMarkdownCapabilitySource).not.toContain("from '@streamdown/code'"); + expect(richMarkdownCapabilitySource).not.toContain("from '@streamdown/mermaid'"); expect(reasoningCapabilitySource).toContain("from './markdown-rendering'"); expect(messageSource).toContain("from '@/capabilities/markdown-rendering'"); From 48b8ae739ff8049f75baec062ebade8631bc3f80 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 14:49:16 +0200 Subject: [PATCH 4/7] docs: record trimmed rich markdown scope --- memory/SPEC.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/memory/SPEC.md b/memory/SPEC.md index 166d46db..969fa775 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -68,22 +68,22 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. architecture (A1, A2, A5, A8–A13, A17–A19, A22–A27). Their truths are now structural properties of the codebase, not open questions. IDs are stable — gaps are intentional. --> -| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | -| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | -| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | -| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | -| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | -| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | -| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, and live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`. Browser outer-loop pending. | -| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | -| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | -| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | -| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | -| A30 | The client can detect when assistant content actually needs rich markdown or diagram enhancement and keep plain text rendering as the immediate default without creating a hydration or streaming mismatch | **validated** | D34, D36 | Refactor commit 4 — progressive rendering split | Validated by `src/client/capabilities/markdown-rendering.test.tsx` (plain path stays immediate, fenced code upgrades after lazy load) plus `src/client/build-boundary.test.ts` (entry excludes `streamdown` and eager highlighter implementation). | -| A31 | A workspace data adapter can centralize the boundary between durable project snapshots, durable entity snapshots, and ephemeral chat state without changing current user-visible behavior before concurrency and hydration policy changes land | **validated** | D37 | Refactor commits 5-7 — workspace state ownership | Validated by `src/client/workspace/workspace-data.test.ts` (durable vs ephemeral seed state separation is explicit and hydration timing is not owned by the adapter) plus unchanged green `src/client/routes/InterviewWorkspace.test.tsx` characterization coverage. | -| A32 | A project-scoped workspace loader can start durable project and entity snapshots together while seeding the entity query cache without reintroducing transcript hydration drift | **validated** | D38 | Refactor commit 6 — workspace loading concurrency | Validated by `src/client/routes/InterviewWorkspace.test.tsx` (initial sidebar data comes from the route loader with no post-mount entity fetch, same-project durable refresh updates sidebar state without rewriting the visible transcript, and observer-result invalidation still refetches entities through the same query boundary). | +| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | +| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | +| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | +| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | +| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | +| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | +| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, and live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`. Browser outer-loop pending. | +| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | +| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | +| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | +| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | +| A30 | The client can detect when assistant content actually needs richer markdown structure and keep plain text rendering as the immediate default without creating a hydration or streaming mismatch | **validated** | D34, D36 | Refactor commit 4 — progressive rendering split | Validated by `src/client/capabilities/markdown-rendering.test.tsx` (plain path stays immediate, fenced content upgrades after lazy load) plus `src/client/build-boundary.test.ts` (entry excludes `streamdown` and eager highlighter implementation). Mermaid graphs and syntax-highlighted code are intentionally out of scope for the shipped app for now. | +| A31 | A workspace data adapter can centralize the boundary between durable project snapshots, durable entity snapshots, and ephemeral chat state without changing current user-visible behavior before concurrency and hydration policy changes land | **validated** | D37 | Refactor commits 5-7 — workspace state ownership | Validated by `src/client/workspace/workspace-data.test.ts` (durable vs ephemeral seed state separation is explicit and hydration timing is not owned by the adapter) plus unchanged green `src/client/routes/InterviewWorkspace.test.tsx` characterization coverage. | +| A32 | A project-scoped workspace loader can start durable project and entity snapshots together while seeding the entity query cache without reintroducing transcript hydration drift | **validated** | D38 | Refactor commit 6 — workspace loading concurrency | Validated by `src/client/routes/InterviewWorkspace.test.tsx` (initial sidebar data comes from the route loader with no post-mount entity fetch, same-project durable refresh updates sidebar state without rewriting the visible transcript, and observer-result invalidation still refetches entities through the same query boundary). | ## Decisions @@ -99,7 +99,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 35. **Developer debug surface is route-lazy, not startup-eager** — The `/debug` route remains declared in the main router, but its UI loads through a lazy client boundary so the default interview entrypoint does not inline developer-only debug content into the initial application chunk. This keeps the route available without charging normal startup for the debug surface. Depends on: D9, D34. Supersedes: eager debug-route component loading from the main router. -36. **Assistant rich rendering is progressive enhancement, not the baseline path** — Message and reasoning text render immediately through a plain text-safe boundary. Rich markdown, diagram rendering, and Shiki-backed highlighting load only after the content proves enhancement is needed, with the rich implementation and highlighter runtime emitted outside the default entry bundle. Depends on: D14, D34. Supersedes: startup-eager `streamdown` + highlighting on the default transcript path. +36. **Assistant rich rendering is progressive enhancement, not the baseline path** — Message and reasoning text render immediately through a plain text-safe boundary. The shipped app currently enhances only general markdown structure (plus lightweight rich rendering plugins such as math/CJK) after content proves enhancement is needed; mermaid graph rendering and syntax-highlighted code fences are intentionally out of scope for now because their bundle cost is not justified by current product needs. Depends on: D14, D34. Supersedes: startup-eager `streamdown` + highlighting on the default transcript path. 37. **Workspace state ownership lives behind a data adapter before semantics change** — The client reads workspace data through an explicit adapter that separates durable project snapshots, durable entity snapshots, and ephemeral chat seed state. This commit preserves current behavior, including the current project-scoped chat hydration boundary, while giving later commits one place to change fetch concurrency and hydration policy without another cross-cutting rewrite. Depends on: D19, D22. Supersedes: inline workspace ownership logic spread across `InterviewWorkspace` and `EntitySidebar`. From a0cbd35849627677151096acefc321625b1fb1a3 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 7 Apr 2026 16:37:30 +0200 Subject: [PATCH 5/7] feat: persist single-option turn responses with optional free-text --- memory/PLAN.md | 18 +- memory/SPEC.md | 167 ++++++++++-------- src/client/mutations/workspace-mutations.ts | 19 +- src/client/routes/InterviewWorkspace.test.tsx | 10 +- src/client/routes/InterviewWorkspace.tsx | 21 ++- src/client/workspace/workspace-controller.ts | 2 +- src/server/app.test.ts | 18 +- src/server/app.ts | 20 ++- src/server/context.test.ts | 36 ++++ src/server/context.ts | 22 ++- src/server/db.test.ts | 2 +- src/server/parts.test.ts | 18 +- src/server/parts.ts | 4 +- src/shared/chat.ts | 34 ++-- 14 files changed, 268 insertions(+), 123 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index eabe6068..53c948fb 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -54,26 +54,28 @@ ### Slices -6c. **Live streaming fix** — Fix the turn-card rendering regression: during live SSE streaming, the structured turn card does not render until page refresh. Thinking streams live; server persists correctly; hydration from DB works. Root cause is in the interaction between `toUIMessageStream()`, `useChat` part accumulation, and the tool-part lifecycle. `in-progress` +6c. **Live streaming fix** — Fix the turn-card rendering regression: during live SSE streaming, the structured turn card does not render until page refresh. Thinking streams live; server persists correctly; hydration from DB works. Root cause is in the interaction between `toUIMessageStream()`, `useChat` part accumulation, and the tool-part lifecycle. `done` - Requirements: → SPEC.md §Requirements #2, #3, #4 - Assumptions: → SPEC.md §Assumptions A16, A28 - Candidate invariant goals: live tool-part rendering matches persisted state after refresh - Invariants to respect: → SPEC.md §Invariants I16, I17, I18, I22 - Invariants established: → SPEC.md §Invariants I43 - Acceptance: send a message in dev, see the structured turn card appear live without refresh; `npm run verify` passes - - **Observed current state (2026-04-07, post-build):** the workspace controller now projects the latest streamed `tool-ask_question` input into the visible `TurnCard` before `onFinish` route invalidation, and targeted regression tests (`InterviewWorkspace`, `workspace-controller`, `workspace-data`, `app`) are green. The slice is still not safely retireable because manual browser verification is pending and `npm run verify` is currently blocked by unrelated repo-wide deprecation lint errors in `src/shared/chat.ts`, `src/client/components/ai-elements/prompt-input.tsx`, and `src/server/observer.ts`. + - **Observed current state (2026-04-07, post-build):** the workspace controller now projects the latest streamed `tool-ask_question` input into the visible `TurnCard` before `onFinish` route invalidation, targeted regression tests (`InterviewWorkspace`, `workspace-controller`, `workspace-data`, `app`) are green, the branch's latest full `npm run verify` passed before the docs-only SPEC commit, and manual browser verification confirmed the live turn card now appears without refresh. - **Observed code seam:** `InterviewWorkspace.renderParts()` still drops `tool-ask_question` transcript parts, but `workspace-controller-core.ts` now projects the latest streamed tool input into a temporary visible turn card while loading; durable loader state still owns the post-finish turn card after router invalidation. - - **Recommended next move for the implementing agent:** run a manual dev/browser walkthrough to confirm the turn card appears live in the real runtime, then retire 6c once the branch's unrelated lint baseline is resolved and `npm run verify` passes. + - **Recommended next move for the implementing agent:** retire 6c and move on to 6d's response-model remodeling work. - **Verification approach**: inner — unit/integration tests for tool-part state transitions or alternate live render path. Outer — manual interview: turn card renders live, matches post-refresh state. -6d. **Flexible turn-response model** — Replace the single-select answer assumption with typed response payloads that support zero/one/many selections, rationale, and custom answers. Keep structured interviewer guidance, recommendation, and strategic grounding, but stop assuming every turn maps to one categorical choice. `not-started` +6d. **Flexible turn-response model** — Replace the single-select answer assumption with typed turn responses that support zero/one/many selections plus unified free-text response content. Keep structured interviewer guidance, recommendation, and strategic grounding, but stop assuming every turn maps to one categorical choice or one scalar answer string. `in-progress` - Requirements: → SPEC.md §Requirements #3, #6 - - Assumptions: → SPEC.md §Assumptions A16, A28 - - Decisions: → SPEC.md §Decisions D23, D24 + - Assumptions: → SPEC.md §Assumptions A16, A28, A33 + - Decisions: → SPEC.md §Decisions D23, D24, D25, D45, D46 - Candidate invariant goals: turn-response payload round-trip fidelity; multi-select/custom-answer state hydrates and replays correctly - Invariants to respect: → SPEC.md §Invariants I17, I18, I19, I22 - - Acceptance: a turn can be answered with multiple selections + rationale or with a custom answer; transcript, persistence, and resume stay aligned - - **Verification approach**: inner — schema + serialization tests for new prompt/response payloads. Outer — manual interview with multi-select and none-of-the-above answers. + - Invariants established: → SPEC.md §Invariants I44, I45 + - Acceptance: a turn can be answered with one-or-more selections plus optional free-text response or with zero selections plus required free-text response; transcript, persistence, interviewer context, and resume stay aligned + - **Observed current state (2026-04-07, tracer bullet 1):** single selected option + optional free-text now persists as `data-turn-response`, stores a user-visible summary seam, rehydrates through the workspace path, and projects into interviewer context as chosen options plus free-text. The remaining work is zero-selection free-text-only handling plus widening from one selected option to true many-selection UX. + - **Verification approach**: inner — response-schema + projection characterization tests (`SPEC.md` §Verification Design, inner loop) prove cardinality and response-shaped context projection; middle — round-trip integration from submit → persistence → hydration → interviewer-context composition (`SPEC.md` §Verification Design, middle loop) validates A33 while protecting I17, I18, I19, and I22; outer — manual interview with option + free-text and free-text-only responses confirms coherent follow-through (`SPEC.md` §Verification Design, outer loop). 6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `not-started` - Requirements: → SPEC.md §Requirements #5, #6, #14 diff --git a/memory/SPEC.md b/memory/SPEC.md index 969fa775..e454a838 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -68,22 +68,24 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. architecture (A1, A2, A5, A8–A13, A17–A19, A22–A27). Their truths are now structural properties of the codebase, not open questions. IDs are stable — gaps are intentional. --> -| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | -| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | -| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | -| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | -| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | -| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | -| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, and live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`. Browser outer-loop pending. | -| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | -| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | -| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | -| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | -| A30 | The client can detect when assistant content actually needs richer markdown structure and keep plain text rendering as the immediate default without creating a hydration or streaming mismatch | **validated** | D34, D36 | Refactor commit 4 — progressive rendering split | Validated by `src/client/capabilities/markdown-rendering.test.tsx` (plain path stays immediate, fenced content upgrades after lazy load) plus `src/client/build-boundary.test.ts` (entry excludes `streamdown` and eager highlighter implementation). Mermaid graphs and syntax-highlighted code are intentionally out of scope for the shipped app for now. | -| A31 | A workspace data adapter can centralize the boundary between durable project snapshots, durable entity snapshots, and ephemeral chat state without changing current user-visible behavior before concurrency and hydration policy changes land | **validated** | D37 | Refactor commits 5-7 — workspace state ownership | Validated by `src/client/workspace/workspace-data.test.ts` (durable vs ephemeral seed state separation is explicit and hydration timing is not owned by the adapter) plus unchanged green `src/client/routes/InterviewWorkspace.test.tsx` characterization coverage. | -| A32 | A project-scoped workspace loader can start durable project and entity snapshots together while seeding the entity query cache without reintroducing transcript hydration drift | **validated** | D38 | Refactor commit 6 — workspace loading concurrency | Validated by `src/client/routes/InterviewWorkspace.test.tsx` (initial sidebar data comes from the route loader with no post-mount entity fetch, same-project durable refresh updates sidebar state without rewriting the visible transcript, and observer-result invalidation still refetches entities through the same query boundary). | + + +| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | +| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | +| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | +| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | +| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | +| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | +| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`, and manual browser verification confirmed the live turn card now appears without refresh. | +| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | +| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | +| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | +| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | +| A33 | Structured turn responses can replace today's single-select flow while keeping persisted response parts, transcript hydration, and interviewer-context projection aligned for the first thin slice | high | D23, D24, D25, D45, D46 | 6d flexible turn-response model | Partially validated for the first tracer bullet: `parts.test.ts`, `app.test.ts`, `context.test.ts`, and `InterviewWorkspace.test.tsx` now prove one selected option plus optional free-text persists, rehydrates, and reaches interviewer context coherently. Zero-selection free-text-only remains to be validated in the follow-on slice. | ## Decisions @@ -117,6 +119,10 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 44. **Domain-shaped client mutations own success choreography above the shared transport seam** — `client-mutation.ts` remains the shared POST/error boundary, but project creation and turn-option selection now flow through domain hooks that own navigation, invalidation, and chat follow-through so route/controller callsites do not repeat workflow logic. Depends on: D40, D43. Supersedes: route- or controller-local success choreography on top of the generic mutation helper. +45. **Choice-turn responses persist as structured data plus a human-readable summary seam** — The single-option response path now writes a `data-turn-response` user part (`selectedOptionIds[]` + optional `freeText`) while `turn.answer` and the persisted text part carry a human-readable summary for transcript, observer, and transport seams. This keeps the response model structured without requiring a migration-hardening bridge for the old scalar-only worldview. Depends on: D23, D24. Supersedes: `data-option-selection` + scalar selected-option persistence. + +46. **Interviewer history prefers response-shaped projection when structured turn-response data exists** — `buildInterviewerContext(...)` should project prior choice-turn replies as chosen options plus free-text response when structured response data exists, falling back to scalar `Answer:` text only for older/non-structured turns. This gives the interviewer coherent access to remodeled response semantics without locking exact prompt prose too early. Depends on: D25, D45. Supersedes: scalar-only `Answer:` projection for choice-turn replies. + 26. **`md-pen` for programmatic markdown rendering** — Structured data (entity tables, dependency graphs, checklists) rendered to markdown via `md-pen` rather than hand-rolled string concatenation. Pure string-return functions (`table()`, `taskList()`, `mermaid()`, `heading()`, `alert()`, `details()`) compose by nesting — no AST, no intermediate representation. Escaping is context-aware per function (table cells, URLs, code fences), eliminating a class of bugs when rendering user-supplied text from interviews. Primary use cases: (1) observer context builders presenting growing entity graphs to agents (`table()` for decisions/assumptions with metadata, `taskList()` for reviewed/unreviewed items), (2) spec export rendering active-path entities into downloadable markdown (slice 13), (3) any future agent-facing or user-facing projection of structured data. Zero dependencies, ESM-only, TypeScript-first. Depends on: —. Supersedes: hand-rolled string assembly in context builders. ### Domain model @@ -131,8 +137,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 13. **Observer captures typed knowledge items plus derived intelligence** — The observer's extraction mandate extends beyond decisions and assumptions to include framing facts, constraints, emerging requirements, criteria-like signals, and derived observations that the interviewer surfaced during the turn. These are persisted in the knowledge layer so subsequent context builders can inject them as context. Supersedes: decisions/assumptions-only observer ontology. 14. **Part-type rendering via AI Elements** — Client renders message parts using AI Elements copy-paste components: `Reasoning` (auto-open/close collapsible with duration), `MessageResponse` (streaming markdown via `streamdown`), `Tool` (7-state collapsible with status badges). `Conversation` provides auto-scroll. `PromptInput` provides `ChatStatus`-aware submit/stop button. shadcn/ui (radix-nova preset) + Tailwind 4 as the styling foundation. Depends on: A16, A17. Supersedes: hand-rolled inline-styled message rendering. 23. **Parts-based persistence model (UIMessage/ModelMessage split)** — Two separate data layers: (1) **UI render state** (`UIMessage.parts[]` JSON) persisted per turn for faithful resume — captures reasoning blocks, tool-call lifecycle states, text, and custom Data Parts. (2) **Inference context** (`ModelMessage`-equivalent) derived at call time by typed context builders, never persisted. The turn tree remains canonical for branching history; parts remain the source of truth for rendering. Prompt/response payload evolution can move independently of persisted UI parts. Research: `docs/research/chat-application-data-models-conversation-turns-structured-data-generative-ui-persistence.md`. Depends on: A22. Supersedes: D15's scalar-only persistence model. -24. **Custom Data Parts model structured user responses beyond single-select choice** — User responses are not always plain text or one categorical pick. AI SDK Data Parts model structured input such as zero/one/many option selections, rationale, custom answer overrides, confirmations, and later review actions. Assistant messages use the same mechanism for domain-specific output such as phase summaries, observer results, and entity snapshots. Depends on: A22, A23. Supersedes: implicit assumption that `turn.answer` is always a text string and that every structured answer is a single selected option. -25. **Typed context builders are phase-aware projections over history + knowledge + readiness** — Different consumers of the turn tree need different projections of the same underlying state. `buildInterviewerContext(...)` provides conversational continuity. `buildObserverContext(...)` provides extraction-optimized context over the current turn plus accumulated knowledge and relevant history summary. Future builders include readiness / review projections for phase-outcome proposal and review modes. Each builder reads from the domain model, not from persisted `UIMessage.parts[]`. Supersedes: single `formatHistory()` function in core.ts. +24. **Custom Data Parts model structured turn responses beyond single-select choice** — User replies are not always plain text or one categorical pick. AI SDK Data Parts model structured input such as zero/one/many option selections, unified free-text response content, confirmations, and later review actions. Assistant messages use the same mechanism for domain-specific output such as phase summaries, observer results, and entity snapshots. Depends on: A22, A23. Supersedes: implicit assumption that a turn's conceptual answer is always one text string and that every structured reply is a single selected option. +25. **Typed context builders are phase-aware projections over history + knowledge + readiness** — Different consumers of the turn tree need different projections of the same underlying state. `buildInterviewerContext(...)` provides conversational continuity. `buildObserverContext(...)` provides extraction-optimized context over the current turn plus accumulated knowledge and relevant history summary. Future builders include readiness / review projections for phase-outcome proposal and review modes. Builders read from the turn domain model first; while no dedicated response table exists, interviewer context may also read persisted structured user parts that are themselves the canonical response representation for a turn. Supersedes: single `formatHistory()` function in core.ts. ### Technical stack @@ -174,8 +180,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | 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 (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 | +| I18 | Parts round-trip fidelity | Slice 4a (parts persistence) | parts.test.ts (7 tests), core.test.ts | D23 | +| I19 | Context builder equivalence | Slice 4a (parts persistence) | context.test.ts (9 tests) | D25 | | I20 | Entity persistence with turn linkage | Slice 5 (observer) | db.test.ts (7 tests), observer.test.ts | D4, D5 | | I21 | Observer-result in-band sync | Slice 5 (observer) | observer.test.ts, app.test.ts | D22 | | I22 | AI SDK-native interviewer path | Slice 6b (AI SDK pivot) | app.test.ts, interview.test.ts | D30, D31 | @@ -200,6 +206,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | I41 | Workspace controller behavior is protected below the route boundary for loader seeding and same-project refresh stability | Refactor commit 14 (controller seam oracles) | workspace-controller.test.tsx | D43 | | I42 | Shared client mutation transport reports network, non-JSON, and malformed-success failures consistently | Refactor commit 14 (mutation seam oracles) | client-mutation.test.ts | D44 | | I43 | Live `tool-ask_question` parts project into the visible turn card before durable route refresh | Slice 6c (live streaming fix) | InterviewWorkspace.test.tsx, workspace-controller.test.tsx | D14, D43 | +| I44 | Choice-turn replies persist as structured turn-response parts plus a user-visible summary on the first remodeled response path | Slice 6d.1 (single-option + free-text response) | parts.test.ts, app.test.ts, InterviewWorkspace.test.tsx | D24, D45 | +| I45 | Interviewer history projects chosen options and free-text from structured turn responses when available | Slice 6d.1 (single-option + free-text response) | context.test.ts | D25, D46 | ## Lexicon @@ -226,8 +234,10 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | **turn** | A branching checkpoint in the interview history. Carries phase provenance plus typed interaction payloads and UI parts. Points to its parent turn — the turn tree is the version history. | | **active path** | The branch from HEAD to root in the turn tree. Determines which turns, knowledge items, phase outcomes, and review state are currently trusted. | | **phase** / **mode** | A workflow stage of the interview: `scope`, `design`, `requirements`, `criteria`. Modes change interviewer behavior, observer extraction bias, and closure logic. They are not exclusive capture windows. | -| **choice turn** | An exploratory interaction turn where the interviewer proposes structured options and strategic grounding. Supports zero/one/many selections, rationale, and custom answers. | +| **choice turn** | An exploratory interaction turn where the interviewer proposes structured options and strategic grounding. Supports zero/one/many selections plus a unified free-text response field that is optional when options are chosen and required when none are chosen. | | **review turn** | A review interaction turn where the interviewer asks the user to approve, edit, reject, merge, or add to a synthesized item set. | +| **turn response** | The full structured user reply to a turn: chosen options plus optional/required free-text content. This is the conceptual answer shape even when compatibility scalars exist in storage or transport seams. | +| **free-text response** | User-authored text attached to a turn response. It can supplement chosen options or stand alone when no option fits. | | **framing** | A contextual truth or intent statement: project goal, actor, user need, workflow context, domain fact, or problem statement. | | **constraint** | A boundary on the acceptable solution space, including hard limits, exclusions, and non-goals. | | **decision** | A chosen fork or commitment in the design tree. Depends on earlier knowledge and can carry rationale. | @@ -282,11 +292,11 @@ Verification is not a phase that follows implementation — it is integral to ev Scored per the arc-oracle diagnostic framework (high / partial / low): -| Dimension | Score | Notes | -| ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Observability** | partial | Inner/middle: high (all text-native — tests, SSE, DB). Outer: low for observer extraction quality (hidden from surface UI) and LLM judgment calls (phase resolution, interview quality). Mitigated by debug mode (planned) and differential testing (spike). | -| **Reproducibility** | partial | Deterministic systems (turn tree, DB, SSE encoding): high. LLM boundary (interviewer output, observer extraction): low — non-deterministic. Mitigated by schema validation (structural) and golden master fixtures with capture-rate thresholds (statistical). | -| **Controllability** | high | Single-user, local SQLite, no external dependencies beyond Claude API. Agent drives full inner loop autonomously (`npm run fix` / `npm run verify`). Human review reserved for outer loop. | +| Dimension | Score | Notes | +| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Observability** | partial | Inner/middle: high for persisted response structure, hydration state, and interviewer-context projection because they are text-native and testable. Outer: still partial for interviewer follow-through quality, which remains visible only through runtime interaction. | +| **Reproducibility** | partial | Deterministic systems (turn tree, DB, schema validation, context projection): high. LLM boundary (interviewer output, observer extraction): low — non-deterministic. For slice 6d we therefore prove structural coherence in middle loop and defer qualitative coherence to the outer loop. | +| **Controllability** | high | Single-user, local SQLite, no external dependencies beyond Claude API. Agent drives full inner loop autonomously (`npm run fix` / `npm run verify`). Human review reserved for outer loop. | ### Verification Commands @@ -312,35 +322,54 @@ End-to-end slices must be **user-testable**, not just programmatically tested. E **Inner loop** (ms–seconds): agent-autonomous, always-on -| Oracle family | What it proves | Protects | Cost | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------- | -| Schema validation | Agent tool output conforms to the active turn/review payload schema for the current mode | I16 (planned) | Negligible — Zod parse on tool output | -| Fast unit tests — SSE | `SDKMessage` → correct SSE event strings | I1, I3, I7 | ms | -| Fast unit tests — DB | Turn persistence with phase provenance, entity writes with dependency edges | I5, I6, I9, I10, I11 | ms | -| Fast unit tests — core | DomainEvent streaming, core/adapter separation, structured turn creation | I12, I13 | ms | -| Fast unit tests — parts | Parts round-trip (DomainEvents → assemble → persist JSON → load → hydrate); Data Part schema validation (Zod parse on structured user input); context builder output shape | I17, I18, I19 | ms | -| Fast unit tests — observer sync | `observer-complete` emitted post-commit with entity IDs matching DB state; SSE adapter encodes as typed data part | D22, A20 | ms | -| Type-aware linting | Semantic static checks (oxlint + tsgolint) | All | ms | +| Oracle family | What it proves | Protects | Cost | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------- | +| Schema validation | Agent tool output conforms to the active turn/review/response payload schema for the current mode | I16 (planned), I17 | Negligible — Zod parse on tool output | +| Fast unit tests — SSE | `SDKMessage` → correct SSE event strings | I1, I3, I7 | ms | +| Fast unit tests — DB | Turn persistence with phase provenance, entity writes with dependency edges | I5, I6, I9, I10, I11 | ms | +| Fast unit tests — core | DomainEvent streaming, core/adapter separation, structured turn creation | I12, I13 | ms | +| Fast unit tests — parts | Parts round-trip (DomainEvents → assemble → persist JSON → load → hydrate); Data Part schema validation (Zod parse on structured user input); context builder output shape | I17, I18, I19 | ms | +| Fast unit tests — turn response | Structured turn-response schema establishes selected-option arrays plus optional free-text for the first tracer bullet; interviewer context projection stays response-shaped, not scalar-only | I17, I18, I19, A33 | ms | +| Fast unit tests — observer sync | `observer-complete` emitted post-commit with entity IDs matching DB state; SSE adapter encodes as typed data part | D22, A20 | ms | +| Type-aware linting | Semantic static checks (oxlint + tsgolint) | All | ms | **Middle loop** (seconds–minutes): regression gates -| Oracle family | What it proves | Protects | Cost | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------- | -| Differential testing (observer) | Observer extraction meets ≥80% entity capture rate against golden master fixtures | A14 | seconds per fixture; requires Claude API | -| Round-trip oracle (turn tree) | Structured turns → active path → entity resolution intact | I6, I9, I10 | ms | -| Integration tests | SSE stream contains expected event types in order; DB lifecycle survives close/reopen | I2, I5, I13, I14 | seconds | -| Round-trip oracle (observer sync) | Full `conductTurn()` with observer → `observer-complete` is last event before `stream-end` → entity IDs in event match committed DB rows | D22 | seconds; requires Claude API | +| Oracle family | What it proves | Protects | Cost | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ---------------------------------------- | +| Differential testing (observer) | Observer extraction meets ≥80% entity capture rate against golden master fixtures | A14 | seconds per fixture; requires Claude API | +| Round-trip oracle (turn response) | Structured turn response survives submit → persistence → hydration → interviewer-context composition with no drift | I17, I18, I19, I22, A33 | seconds | +| Round-trip oracle (turn tree) | Structured turns → active path → entity resolution intact | I6, I9, I10 | ms | +| Integration tests | SSE stream contains expected event types in order; DB lifecycle survives close/reopen | I2, I5, I13, I14 | seconds | +| Round-trip oracle (observer sync) | Full `conductTurn()` with observer → `observer-complete` is last event before `stream-end` → entity IDs in event match committed DB rows | D22 | seconds; requires Claude API | **Outer loop** (minutes–hours): human observer -| Oracle family | What it proves | Cost | -| -------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------- | -| Debug mode (observer visibility) | Observer extraction is inspectable per-turn during manual testing | UI delta on slice 5/6 | -| Manual interview walkthrough | Structured questions render correctly; interview quality is acceptable | Human time | -| Fixture capture from manual runs | Bootstrap golden master fixtures by querying DB after confirmed-good sessions | Human judgment + SQL query | -| Rich chat rendering | Tool call states, reasoning collapse, message parts render by type | Human + `/cli-cdp` | -| Resume test | Close/reopen browser, verify state intact | Human + browser | -| Observer → sidebar reactivity | `onData` → `setQueryData` bridge updates sidebar after observer extraction; validates A21 | Human + `/cli-cdp` (slice 6) | +| Oracle family | What it proves | Cost | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | +| Debug mode (observer visibility) | Observer extraction is inspectable per-turn during manual testing | UI delta on slice 5/6 | +| Manual interview walkthrough — turn response | Option + free-text and free-text-only responses remain coherent in runtime and give the interviewer enough structured context for a sensible follow-up | Human + browser | +| Manual interview walkthrough | Structured questions render correctly; interview quality is acceptable | Human time | +| Fixture capture from manual runs | Bootstrap golden master fixtures by querying DB after confirmed-good sessions | Human judgment + SQL query | +| Rich chat rendering | Tool call states, reasoning collapse, message parts render by type | Human + `/cli-cdp` | +| Resume test | Close/reopen browser, verify state intact | Human + browser | +| Observer → sidebar reactivity | `onData` → query invalidation updates sidebar after observer extraction; validates A21 | Human + `/cli-cdp` (slice 6) | + +### Design notes + +- **6d response-model oracle boundary** — Middle-loop oracles for slice 6d prove structural coherence only: the same turn response shape must survive submit, persistence, hydration, and interviewer-context projection. They do not attempt to score the quality of the next interviewer turn. +- **Unified free-text field** — For slice 6d, rationale and custom-answer semantics are intentionally unified into one free-text response field. The oracle locks the cardinality rule: free text is optional when at least one option is chosen and required when zero options are chosen. +- **Response-shaped projection over scalar answer wording** — The interviewer projection oracle should lock grouping and presence of chosen options and free-text content, but not exact prose. The goal is to move away from scalar-only `Answer:` semantics without overfitting prompt wording too early. +- **No bridge oracle for `turn.answer`** — Because the project is still early and a breaking change is acceptable, oracle design does not spend budget on proving compatibility behavior for a scalar `turn.answer` seam. + +### Acknowledged Blind Spots + +| Blind spot | Reason | Mitigation | Revisit trigger | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Next-turn interviewer quality after response remodeling | Qualitative LLM behavior is non-deterministic and too expensive to gate in the middle loop while the response shape is still moving | Manual interview walkthrough in outer loop | Revisit once response shape stabilizes across several remodelings | +| Exact interviewer projection wording | Prompt prose is still evolving; locking exact labels now would create churn without much signal | Lock structure/grouping rather than exact text in projection tests | Revisit when prompt vocabulary and transcript copy settle | +| Full breadth of future response variants | Slice 6d starts by proving the general response-model seam, not every future UI variant or review action | Keep schema and projection extensible; add focused slice oracles as variants land | Revisit when multi-select UX and later review actions are implemented | +| Legacy scalar-answer compatibility | Existing data is not important enough to justify migration hardening, and a breaking change is acceptable | Skip bridge oracle; refactor directly toward the structured response model | Revisit only if an external consumer starts depending on the scalar seam | ### Observer History Projection @@ -369,26 +398,26 @@ This projection difference is a deliberate design choice, not an implementation -| File | Tests | Protects | -| ----------------------------- | ----- | -------------------------------------- | -| db.test.ts | 32 | I5, I6, I9, I10, I11, I20 | -| app.test.ts | 6 | I1, I2, I3, I7, I14, I21, I23 | -| core.test.ts | 6 | I12, I13, I18 | -| interview.test.ts | 6 | I16 | -| parts.test.ts | 7 | I17, I18 | -| context.test.ts | 8 | I19 | -| observer.test.ts | 2 | I20, I21 | -| InterviewWorkspace.test.tsx | 7 | I24, I25, I23, I33, I34, I35, I36, I43 | -| ProjectList.test.tsx | 2 | I36 | -| workspace-data.test.ts | 4 | I33 | -| chat-hydration.test.ts | 3 | I35 | -| workspace-controller.test.tsx | 3 | I41, I43 | -| client-mutation.test.ts | 3 | I42 | -| code-block.test.tsx | 4 | I26, I37, I39 | -| markdown-rendering.test.tsx | 3 | I31, I39 | -| message.test.tsx | 2 | I27, I38 | -| build-boundary.test.ts | 1 | I28, I30, I32, I40 | -| capability-boundaries.test.ts | 2 | I29, I39 | +| File | Tests | Protects | +| ----------------------------- | ----- | ------------------------------------------- | +| db.test.ts | 32 | I5, I6, I9, I10, I11, I20 | +| app.test.ts | 6 | I1, I2, I3, I7, I14, I21, I23, I44 | +| core.test.ts | 6 | I12, I13, I18 | +| interview.test.ts | 6 | I16 | +| parts.test.ts | 7 | I17, I18, I44 | +| context.test.ts | 9 | I19, I45 | +| observer.test.ts | 2 | I20, I21 | +| InterviewWorkspace.test.tsx | 7 | I24, I25, I23, I33, I34, I35, I36, I43, I44 | +| ProjectList.test.tsx | 2 | I36 | +| workspace-data.test.ts | 4 | I33 | +| chat-hydration.test.ts | 3 | I35 | +| workspace-controller.test.tsx | 3 | I41, I43 | +| client-mutation.test.ts | 3 | I42 | +| code-block.test.tsx | 4 | I26, I37, I39 | +| markdown-rendering.test.tsx | 3 | I31, I39 | +| message.test.tsx | 2 | I27, I38 | +| build-boundary.test.ts | 1 | I28, I30, I32, I40 | +| capability-boundaries.test.ts | 2 | I29, I39 | ## Acceptance Criteria (exit conditions) diff --git a/src/client/mutations/workspace-mutations.ts b/src/client/mutations/workspace-mutations.ts index 6da716fd..c8cf1e29 100644 --- a/src/client/mutations/workspace-mutations.ts +++ b/src/client/mutations/workspace-mutations.ts @@ -1,6 +1,7 @@ import { useRouter } from '@tanstack/react-router'; import type { ProjectStateTurn } from '../../shared/api-types.js'; +import { formatTurnResponseText } from '../../shared/chat.js'; import { findTurnOptionByPosition } from '../workspace/workspace-controller-core.js'; import { postJsonMutation, useClientMutation } from './client-mutation.js'; @@ -14,25 +15,31 @@ export function useSelectTurnOptionMutation({ sendMessage: (message: { text: string }) => Promise | void; }) { const router = useRouter(); - const mutation = useClientMutation((variables: { turnId: number; position: number }) => - postJsonMutation<{ ok: boolean }, { position: number }>( + const mutation = useClientMutation((variables: { turnId: number; position: number; freeText?: string }) => + postJsonMutation<{ ok: boolean }, { position: number; freeText?: string }>( `/api/projects/${projectId}/turns/${variables.turnId}/select`, - { position: variables.position }, + { position: variables.position, ...(variables.freeText ? { freeText: variables.freeText } : {}) }, 'Failed to save selection', ), ); return { - selectOption: async (position: number) => { + selectOption: async (position: number, freeText?: string) => { const selected = findTurnOptionByPosition(turn, position); if (!selected || !turn) { return; } + const trimmedFreeText = freeText?.trim(); try { - await mutation.run({ turnId: turn.id, position }); + await mutation.run({ turnId: turn.id, position, freeText: trimmedFreeText || undefined }); await router.invalidate(); - await sendMessage({ text: selected.content }); + await sendMessage({ + text: formatTurnResponseText({ + selectedOptionContents: [selected.content], + freeText: trimmedFreeText, + }), + }); } catch { // The shared mutation hook surfaces the failure state in the UI. } diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx index 04d6565c..c426e70b 100644 --- a/src/client/routes/InterviewWorkspace.test.tsx +++ b/src/client/routes/InterviewWorkspace.test.tsx @@ -455,7 +455,7 @@ describe('InterviewWorkspace', () => { }); }); - it('posts option selections, refreshes project state, and forwards the selected text back into chat', async () => { + it('posts single-option turn responses with optional free-text and forwards a combined summary into chat', async () => { currentLoaderData = createWorkspaceLoaderData({ options: [ { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }, @@ -472,6 +472,10 @@ describe('InterviewWorkspace', () => { renderWorkspace(); + fireEvent.change(await screen.findByLabelText('Additional response context'), { + target: { value: 'Best fit for our launch' }, + }); + fireEvent.click(await screen.findByRole('button', { name: /desktop/i })); await waitFor(() => { @@ -480,14 +484,14 @@ describe('InterviewWorkspace', () => { expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ position: 1 }), + body: JSON.stringify({ position: 1, freeText: 'Best fit for our launch' }), }), ); }); await waitFor(() => { expect(routerInvalidate).toHaveBeenCalledTimes(1); - expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ text: 'Desktop' }); + expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ text: 'Desktop — Best fit for our launch' }); }); }); diff --git a/src/client/routes/InterviewWorkspace.tsx b/src/client/routes/InterviewWorkspace.tsx index 6d67ebfa..105355c0 100644 --- a/src/client/routes/InterviewWorkspace.tsx +++ b/src/client/routes/InterviewWorkspace.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router'; +import { useState } from 'react'; import { Conversation, @@ -35,11 +36,12 @@ function TurnCard({ disabled, }: { turn: ProjectStateTurn; - onSelect: (position: number) => void | Promise; + onSelect: (position: number, freeText?: string) => void | Promise; disabled: boolean; }) { const options = turn.options ?? []; const hasSelection = options.some((o) => o.is_selected); + const [freeText, setFreeText] = useState(''); return (
@@ -58,6 +60,21 @@ function TurnCard({ )} +
+ +