@@ -58,31 +74,80 @@ function TurnCard({
)}
+
+
+
+
{options.map((opt) => {
- const isSelected = opt.is_selected;
+ const isSelected = selectedPositions.includes(opt.position);
return (
-
+ toggleSelection(opt.position)}
+ disabled={disabled || hasPersistedSelection}
+ aria-label={opt.content}
+ />
+
+ {opt.content}
+ {opt.is_recommended && (
+ Recommended
+ )}
+ {isSelected && (
+ ✓ Selected
+ )}
+
+
);
})}
@@ -166,8 +231,9 @@ export function InterviewWorkspace() {
{turnCard && (
)}
diff --git a/src/client/workspace/workspace-controller-core.ts b/src/client/workspace/workspace-controller-core.ts
index e806ca7f..bb5e247a 100644
--- a/src/client/workspace/workspace-controller-core.ts
+++ b/src/client/workspace/workspace-controller-core.ts
@@ -214,3 +214,8 @@ export function createWorkspaceControllerViewState(
export function findTurnOptionByPosition(turn: ProjectStateTurn | undefined, position: number) {
return turn?.options?.find((option) => option.position === position);
}
+
+export function findTurnOptionsByPositions(turn: ProjectStateTurn | undefined, positions: number[]) {
+ const uniquePositions = [...new Set(positions)];
+ return turn?.options?.filter((option) => uniquePositions.includes(option.position)) ?? [];
+}
diff --git a/src/client/workspace/workspace-controller.ts b/src/client/workspace/workspace-controller.ts
index 2c9305c5..1bcb7aaa 100644
--- a/src/client/workspace/workspace-controller.ts
+++ b/src/client/workspace/workspace-controller.ts
@@ -3,7 +3,7 @@ import { useLoaderData, useParams, useRouter } from '@tanstack/react-router';
import { DefaultChatTransport, type ChatStatus } from 'ai';
import { useCallback, useMemo } from 'react';
-import { useSelectTurnOptionMutation } from '@/mutations/workspace-mutations';
+import { useSubmitTurnResponseMutation } from '@/mutations/workspace-mutations';
import type { ProjectStateTurn } from '../../shared/api-types.js';
import { brunchDataPartSchemas, type BrunchUIMessage } from '../../shared/chat.js';
@@ -27,7 +27,7 @@ export interface WorkspaceControllerTurnCardState {
turn: ProjectStateTurn;
disabled: boolean;
errorMessage: string | null;
- selectOption: (position: number) => Promise
;
+ submitTurnResponse: (positions: number[], freeText?: string) => Promise;
}
export interface WorkspaceControllerPromptInputState {
@@ -65,7 +65,7 @@ export function useWorkspaceController(): WorkspaceController {
void router.invalidate();
},
});
- const selectOptionMutation = useSelectTurnOptionMutation({
+ const submitTurnResponseMutation = useSubmitTurnResponseMutation({
projectId,
turn: durableProject.lastTurn,
sendMessage,
@@ -103,14 +103,14 @@ export function useWorkspaceController(): WorkspaceController {
turnCard: viewState.turnCard
? {
turn: viewState.turnCard.turn,
- disabled: selectOptionMutation.isPending || isLoading,
- errorMessage: selectOptionMutation.errorMessage,
- selectOption: selectOptionMutation.selectOption,
+ disabled: submitTurnResponseMutation.isPending || isLoading,
+ errorMessage: submitTurnResponseMutation.errorMessage,
+ submitTurnResponse: submitTurnResponseMutation.submitTurnResponse,
}
: null,
promptInput: {
visible: viewState.promptInput.visible,
- disabled: isLoading || selectOptionMutation.isPending,
+ disabled: isLoading || submitTurnResponseMutation.isPending,
},
};
}
diff --git a/src/server/app.test.ts b/src/server/app.test.ts
index fad48936..d7b71c1d 100644
--- a/src/server/app.test.ts
+++ b/src/server/app.test.ts
@@ -217,7 +217,7 @@ describe('GET /api/projects/:id', () => {
});
describe('POST /api/projects/:id/turns/:turnId/select', () => {
- it('persists the selected option into answer and user parts', async () => {
+ it('persists the selected option and free-text turn response into answer and user parts', async () => {
const projectId = await createTestProject();
mockStreamInterviewer.mockImplementation(async (dbArg, turn) =>
makeStructuredQuestionInterviewer(dbArg as DB, (turn as { id: number }).id),
@@ -235,13 +235,118 @@ describe('POST /api/projects/:id/turns/:turnId/select', () => {
await request(app)
.post(`/api/projects/${projectId}/turns/${turn.id}/select`)
- .send({ position: 1 })
+ .send({ positions: [1], freeText: 'Best fit for our launch' })
.expect(200);
expect(getOptionsForTurn(db, turn.id)[1].is_selected).toBe(true);
- expect(getTurn(db, turn.id)?.answer).toBe('Desktop');
+ expect(getTurn(db, turn.id)?.answer).toBe('Desktop — Best fit for our launch');
const userParts = JSON.parse(getTurn(db, turn.id)?.user_parts ?? '[]');
- expect(userParts.some((part: { type: string }) => part.type === 'data-option-selection')).toBe(true);
+ expect(userParts).toEqual([
+ { type: 'text', text: 'Desktop — Best fit for our launch' },
+ {
+ type: 'data-turn-response',
+ data: {
+ turnId: turn.id,
+ selectedOptionIds: [getOptionsForTurn(db, turn.id)[1].id],
+ freeText: 'Best fit for our launch',
+ },
+ },
+ ]);
+ });
+
+ it('persists many selected options and free-text turn responses into answer and user parts', async () => {
+ const projectId = await createTestProject();
+ mockStreamInterviewer.mockImplementation(async (dbArg, turn) =>
+ makeStructuredQuestionInterviewer(dbArg as DB, (turn as { id: number }).id),
+ );
+
+ await request(app)
+ .post(`/api/projects/${projectId}/chat`)
+ .send({
+ messages: [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
+ })
+ .expect(200);
+
+ const { getActivePath, getTurn, getOptionsForTurn } = await import('./db.js');
+ const turn = getActivePath(db, projectId)[0];
+
+ await request(app)
+ .post(`/api/projects/${projectId}/turns/${turn.id}/select`)
+ .send({ positions: [0, 1], freeText: 'Covers both launch paths' })
+ .expect(200);
+
+ const selectedOptions = getOptionsForTurn(db, turn.id).filter((option) => option.is_selected);
+ expect(selectedOptions.map((option) => option.content)).toEqual(['Web', 'Desktop']);
+ expect(getTurn(db, turn.id)?.answer).toBe('Web, Desktop — Covers both launch paths');
+
+ const userParts = JSON.parse(getTurn(db, turn.id)?.user_parts ?? '[]');
+ expect(userParts).toEqual([
+ { type: 'text', text: 'Web, Desktop — Covers both launch paths' },
+ {
+ type: 'data-turn-response',
+ data: {
+ turnId: turn.id,
+ selectedOptionIds: selectedOptions.map((option) => option.id),
+ freeText: 'Covers both launch paths',
+ },
+ },
+ ]);
+ });
+
+ it('persists a free-text-only turn response when no option is selected', async () => {
+ const projectId = await createTestProject();
+ mockStreamInterviewer.mockImplementation(async (dbArg, turn) =>
+ makeStructuredQuestionInterviewer(dbArg as DB, (turn as { id: number }).id),
+ );
+
+ await request(app)
+ .post(`/api/projects/${projectId}/chat`)
+ .send({
+ messages: [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
+ })
+ .expect(200);
+
+ const { getActivePath, getTurn, getOptionsForTurn } = await import('./db.js');
+ const turn = getActivePath(db, projectId)[0];
+
+ await request(app)
+ .post(`/api/projects/${projectId}/turns/${turn.id}/select`)
+ .send({ freeText: 'None of these fit our use case' })
+ .expect(200);
+
+ expect(getOptionsForTurn(db, turn.id).every((option) => !option.is_selected)).toBe(true);
+ expect(getTurn(db, turn.id)?.answer).toBe('None of these fit our use case');
+
+ const userParts = JSON.parse(getTurn(db, turn.id)?.user_parts ?? '[]');
+ expect(userParts).toEqual([
+ { type: 'text', text: 'None of these fit our use case' },
+ {
+ type: 'data-turn-response',
+ data: { turnId: turn.id, selectedOptionIds: [], freeText: 'None of these fit our use case' },
+ },
+ ]);
+ });
+
+ it('rejects a free-text-only turn response when no free text is provided', async () => {
+ const projectId = await createTestProject();
+ mockStreamInterviewer.mockImplementation(async (dbArg, turn) =>
+ makeStructuredQuestionInterviewer(dbArg as DB, (turn as { id: number }).id),
+ );
+
+ await request(app)
+ .post(`/api/projects/${projectId}/chat`)
+ .send({
+ messages: [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
+ })
+ .expect(200);
+
+ const { getActivePath } = await import('./db.js');
+ const turn = getActivePath(db, projectId)[0];
+
+ await request(app)
+ .post(`/api/projects/${projectId}/turns/${turn.id}/select`)
+ .send({ freeText: ' ' })
+ .expect(400);
});
});
diff --git a/src/server/app.ts b/src/server/app.ts
index e341018b..dec8f15c 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -8,6 +8,7 @@ import {
brunchDataPartSchemas,
brunchValidationTools,
extractTextFromMessage,
+ formatTurnResponseText,
type BrunchAssistantPart,
type BrunchUIMessage,
type BrunchUserPart,
@@ -24,7 +25,7 @@ import {
createDb,
getTurn,
getOptionsForTurn,
- selectOption,
+ selectOptions,
updateTurn,
getEntitiesForProject,
} from './db.js';
@@ -68,18 +69,24 @@ export function createApp(dbPath?: string) {
res.json(state satisfies ProjectState);
});
- // Select an option on a turn
+ // Submit a turn response on a turn
app.post('/api/projects/:id/turns/:turnId/select', (req: Request, res: Response) => {
const projectId = Number(req.params.id);
const turnId = Number(req.params.turnId);
- const position = req.body?.position;
+ const positions: number[] = Array.isArray(req.body?.positions)
+ ? req.body.positions.filter((value: unknown): value is number => typeof value === 'number')
+ : typeof req.body?.position === 'number'
+ ? [req.body.position]
+ : [];
+ const uniquePositions: number[] = [...new Set(positions)];
+ const freeText = typeof req.body.freeText === 'string' ? req.body.freeText.trim() : undefined;
if (Number.isNaN(projectId) || Number.isNaN(turnId)) {
res.status(400).json({ error: 'Invalid IDs' });
return;
}
- if (typeof position !== 'number') {
- res.status(400).json({ error: 'position is required (number)' });
+ if (uniquePositions.length === 0 && !freeText) {
+ res.status(400).json({ error: 'positions are required unless freeText is provided' });
return;
}
@@ -89,20 +96,30 @@ export function createApp(dbPath?: string) {
return;
}
- selectOption(db, turnId, position);
-
const options = getOptionsForTurn(db, turnId);
- const selected = options.find((o) => o.position === position);
+ const selectedOptions = options.filter((option) => uniquePositions.includes(option.position));
+ if (selectedOptions.length !== uniquePositions.length) {
+ res.status(400).json({ error: 'Selected option not found' });
+ return;
+ }
+ selectOptions(db, turnId, uniquePositions);
+
+ const selectedOptionIds = selectedOptions.map((option) => option.id);
+ const selectedOptionContents = selectedOptions.map((option) => option.content);
+ const responseText = formatTurnResponseText({
+ selectedOptionContents,
+ freeText,
+ });
const dataPart = {
- type: 'data-option-selection',
- data: { turnId, selectedOptionId: selected?.id ?? position },
- } as const satisfies Extract;
+ type: 'data-turn-response',
+ data: { turnId, selectedOptionIds, ...(freeText ? { freeText } : {}) },
+ } as const satisfies Extract;
updateTurn(db, turnId, {
- answer: selected?.content ?? '',
+ answer: responseText,
user_parts: serializeParts([
- ...(selected?.content ? ([{ type: 'text', text: selected.content }] as const) : []),
+ ...(responseText ? ([{ type: 'text', text: responseText }] as const) : []),
dataPart,
] satisfies BrunchUserPart[]),
});
@@ -152,9 +169,7 @@ export function createApp(dbPath?: string) {
lastUserMessage?.role === 'user' && lastUserMessage.parts.length > 0
? lastUserMessage.parts.filter(
(part): part is BrunchUserPart =>
- part.type === 'text' ||
- part.type === 'data-option-selection' ||
- part.type === 'data-confirmation',
+ part.type === 'text' || part.type === 'data-turn-response' || part.type === 'data-confirmation',
)
: [{ type: 'text', text: prompt }];
diff --git a/src/server/context.test.ts b/src/server/context.test.ts
index aa22cc9e..508f5984 100644
--- a/src/server/context.test.ts
+++ b/src/server/context.test.ts
@@ -65,6 +65,114 @@ describe('buildInterviewerContext', () => {
expect(result).toContain('[selected]');
});
+ it('projects selected options and free-text response as structured history', () => {
+ const turns: TurnWithOptions[] = [
+ {
+ id: 1,
+ project_id: 1,
+ parent_turn_id: null,
+ phase: 'scope',
+ question: 'Which platform should we target?',
+ answer: 'Desktop — Best fit for our launch',
+ why: 'Platform shapes the first build.',
+ impact: 'high',
+ is_resolution: false,
+ user_parts: JSON.stringify([
+ { type: 'text', text: 'Desktop — Best fit for our launch' },
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [12], freeText: 'Best fit for our launch' },
+ },
+ ]),
+ assistant_parts: null,
+ created_at: '2026-01-01',
+ options: [
+ { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false },
+ { id: 12, position: 1, content: 'Desktop', is_recommended: false, is_selected: true },
+ ],
+ },
+ ];
+
+ const result = buildInterviewerContext(turns, 'next');
+
+ expect(result).toContain('Turn response:');
+ expect(result).toContain('Chosen options: Desktop');
+ expect(result).toContain('Free-text response: Best fit for our launch');
+ expect(result).not.toContain('Answer: Desktop — Best fit for our launch');
+ });
+
+ it('projects free-text-only turn responses as structured history', () => {
+ const turns: TurnWithOptions[] = [
+ {
+ id: 1,
+ project_id: 1,
+ parent_turn_id: null,
+ phase: 'scope',
+ question: 'Which platform should we target?',
+ answer: 'None of these fit our use case',
+ why: 'Platform shapes the first build.',
+ impact: 'high',
+ is_resolution: false,
+ user_parts: JSON.stringify([
+ { type: 'text', text: 'None of these fit our use case' },
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [], freeText: 'None of these fit our use case' },
+ },
+ ]),
+ assistant_parts: null,
+ created_at: '2026-01-01',
+ options: [
+ { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false },
+ { id: 12, position: 1, content: 'Desktop', is_recommended: false, is_selected: false },
+ ],
+ },
+ ];
+
+ const result = buildInterviewerContext(turns, 'next');
+
+ expect(result).toContain('Turn response:');
+ expect(result).not.toContain('Chosen options:');
+ expect(result).toContain('Free-text response: None of these fit our use case');
+ expect(result).not.toContain('Answer: None of these fit our use case');
+ });
+
+ it('projects many selected options as one structured turn response', () => {
+ const turns: TurnWithOptions[] = [
+ {
+ id: 1,
+ project_id: 1,
+ parent_turn_id: null,
+ phase: 'scope',
+ question: 'Which platform should we target?',
+ answer: 'Web, Desktop — Covers both launch paths',
+ why: 'Platform shapes the first build.',
+ impact: 'high',
+ is_resolution: false,
+ user_parts: JSON.stringify([
+ { type: 'text', text: 'Web, Desktop — Covers both launch paths' },
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [11, 12], freeText: 'Covers both launch paths' },
+ },
+ ]),
+ assistant_parts: null,
+ created_at: '2026-01-01',
+ options: [
+ { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: true },
+ { id: 12, position: 1, content: 'Desktop', is_recommended: false, is_selected: true },
+ ],
+ },
+ ];
+
+ const result = buildInterviewerContext(turns, 'next');
+
+ expect(result).toContain('Turn response:');
+ expect(result).toContain('Chosen options: Web, Desktop');
+ expect(result).toContain('Free-text response: Covers both launch paths');
+ expect(result).not.toContain('Answer: Web, Desktop — Covers both launch paths');
+ });
+
it('handles multi-turn history', () => {
const turns: TurnWithOptions[] = [
{
diff --git a/src/server/context.ts b/src/server/context.ts
index d1884e5b..2c33596e 100644
--- a/src/server/context.ts
+++ b/src/server/context.ts
@@ -2,11 +2,13 @@ import { table, h3 } from 'md-pen';
import type { TurnWithOptions } from './core.js';
import type { Turn } from './db.js';
+import { safeDeserializeUserParts, type UserPart } from './parts.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.
+ * Reads from the turn domain model, including persisted structured response parts
+ * while there is no dedicated response table yet.
*/
export function buildInterviewerContext(turns: TurnWithOptions[], currentPrompt: string): string {
if (turns.length === 0) return currentPrompt;
@@ -28,7 +30,23 @@ export function buildInterviewerContext(turns: TurnWithOptions[], currentPrompt:
}
lines.push(questionLine);
}
- if (turn.answer) lines.push(`Answer: ${turn.answer}`);
+ const selectedOptions =
+ turn.options?.filter((option) => option.is_selected).map((option) => option.content) ?? [];
+ const freeText = safeDeserializeUserParts(turn.user_parts).find(
+ (part): part is Extract => part.type === 'data-turn-response',
+ )?.data.freeText;
+ if (selectedOptions.length > 0 || freeText) {
+ const responseLines = ['Turn response:'];
+ if (selectedOptions.length > 0) {
+ responseLines.push(` Chosen options: ${selectedOptions.join(', ')}`);
+ }
+ if (freeText) {
+ responseLines.push(` Free-text response: ${freeText}`);
+ }
+ lines.push(responseLines.join('\n'));
+ } else 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}`;
diff --git a/src/server/db.test.ts b/src/server/db.test.ts
index f0579063..9d26d5e9 100644
--- a/src/server/db.test.ts
+++ b/src/server/db.test.ts
@@ -349,7 +349,7 @@ describe('DB lifecycle — parts persistence', () => {
{ type: 'text', text: 'answer' },
]);
const userParts = JSON.stringify([
- { type: 'data-option-selection', data: { turnId: turn.id, selectedOptionId: 0 } },
+ { type: 'data-turn-response', data: { turnId: turn.id, selectedOptionIds: [0] } },
]);
updateTurn(db1, turn.id, { assistant_parts: parts, user_parts: userParts });
advanceHead(db1, project.id, turn.id);
diff --git a/src/server/db.ts b/src/server/db.ts
index 061956bb..5b782744 100644
--- a/src/server/db.ts
+++ b/src/server/db.ts
@@ -1,5 +1,5 @@
import Database from 'better-sqlite3';
-import { desc, eq, sql, type InferSelectModel } from 'drizzle-orm';
+import { and, desc, eq, inArray, sql, type InferSelectModel } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
@@ -157,13 +157,20 @@ export function getOptionsForTurn(db: DB, turnId: number): Option[] {
.all() as Option[];
}
-export function selectOption(db: DB, turnId: number, position: number): void {
+export function selectOptions(db: DB, turnId: number, positions: number[]): void {
+ const uniquePositions = [...new Set(positions)];
+
// Clear any previous selection for this turn
db.update(schema.option).set({ is_selected: false }).where(eq(schema.option.turn_id, turnId)).run();
- // Select the chosen option
+
+ if (uniquePositions.length === 0) {
+ return;
+ }
+
+ // Select the chosen options
db.update(schema.option)
.set({ is_selected: true })
- .where(sql`${schema.option.turn_id} = ${turnId} AND ${schema.option.position} = ${position}`)
+ .where(and(eq(schema.option.turn_id, turnId), inArray(schema.option.position, uniquePositions)))
.run();
}
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/server/parts.test.ts b/src/server/parts.test.ts
index 87519b16..0f5dd2bb 100644
--- a/src/server/parts.test.ts
+++ b/src/server/parts.test.ts
@@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
dataConfirmationSchema,
- dataOptionSelectionSchema,
type BrunchAssistantPart,
type BrunchUserPart,
+ userPartsSchema,
} from '../shared/chat.js';
import { createDb, type DB } from './db.js';
import {
@@ -36,9 +36,45 @@ describe('migration-adds-parts-columns', () => {
});
describe('data schemas', () => {
- it('validates data-option-selection payloads', () => {
- const value = { turnId: 1, selectedOptionId: 2, rationale: 'Best fit' };
- expect(dataOptionSelectionSchema.parse(value)).toEqual(value);
+ it('validates data-turn-response payloads', () => {
+ const value = [
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [2], freeText: 'Best fit' },
+ },
+ ];
+
+ expect(userPartsSchema.parse(value)).toEqual(value);
+ });
+
+ it('validates data-turn-response payloads with many selected options', () => {
+ const value = [
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [2, 3], freeText: 'Need both' },
+ },
+ ];
+
+ expect(userPartsSchema.parse(value)).toEqual(value);
+ });
+
+ it('validates free-text-only data-turn-response payloads and rejects empty ones', () => {
+ const validValue = [
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [], freeText: 'None of these fit our use case' },
+ },
+ ];
+
+ expect(userPartsSchema.parse(validValue)).toEqual(validValue);
+ expect(() =>
+ userPartsSchema.parse([
+ {
+ type: 'data-turn-response',
+ data: { turnId: 1, selectedOptionIds: [] },
+ },
+ ]),
+ ).toThrow();
});
it('validates data-confirmation payloads', () => {
@@ -86,14 +122,24 @@ describe('assistant part round-trip', () => {
describe('user part round-trip', () => {
it('round-trips persisted user parts', () => {
const parts: BrunchUserPart[] = [
- { type: 'text', text: 'Web first' },
- { type: 'data-option-selection', data: { turnId: 4, selectedOptionId: 9 } },
+ { type: 'text', text: 'Web first — Best fit' },
+ { type: 'data-turn-response', data: { turnId: 4, selectedOptionIds: [9], freeText: 'Best fit' } },
{ type: 'data-confirmation', data: { turnId: 4, confirmed: true } },
];
const json = serializeParts(parts);
expect(deserializeUserParts(json)).toEqual(parts);
});
+
+ it('round-trips persisted user parts with many selected option ids', () => {
+ const parts: BrunchUserPart[] = [
+ { type: 'text', text: 'Web, Desktop — Need both' },
+ { type: 'data-turn-response', data: { turnId: 4, selectedOptionIds: [9, 10], freeText: 'Need both' } },
+ ];
+
+ const json = serializeParts(parts);
+ expect(deserializeUserParts(json)).toEqual(parts);
+ });
});
describe('safe deserialization', () => {
diff --git a/src/server/parts.ts b/src/server/parts.ts
index 7283c141..c8020a45 100644
--- a/src/server/parts.ts
+++ b/src/server/parts.ts
@@ -7,9 +7,9 @@ import {
export type AssistantPart = BrunchAssistantPart;
export type UserPart = BrunchUserPart;
-export type DataOptionSelection = import('../shared/chat.js').DataOptionSelection;
+export type DataTurnResponse = import('../shared/chat.js').DataTurnResponse;
export type DataConfirmation = import('../shared/chat.js').DataConfirmation;
-export type DataOptionSelectionPart = Extract;
+export type DataTurnResponsePart = Extract;
export type DataConfirmationPart = Extract;
/** Serialize parts to JSON for persistence. */
diff --git a/src/shared/chat.ts b/src/shared/chat.ts
index af350a32..1164c5fc 100644
--- a/src/shared/chat.ts
+++ b/src/shared/chat.ts
@@ -28,11 +28,21 @@ export const observerResultSchema = z.object({
}),
});
-export const dataOptionSelectionSchema = z.object({
- turnId: z.number(),
- selectedOptionId: z.number(),
- rationale: z.string().optional(),
-});
+export const dataTurnResponseSchema = z
+ .object({
+ turnId: z.number(),
+ selectedOptionIds: z.array(z.number()),
+ freeText: z.string().trim().min(1).optional(),
+ })
+ .superRefine((value, ctx) => {
+ if (value.selectedOptionIds.length === 0 && !value.freeText) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'freeText is required when no options are selected',
+ path: ['freeText'],
+ });
+ }
+ });
export const dataConfirmationSchema = z.object({
turnId: z.number(),
@@ -47,7 +57,7 @@ export const dataPhaseSummarySchema = z.object({
export type StructuredQuestion = z.infer;
export type AskQuestionToolOutput = z.infer;
export type ObserverResultData = z.infer;
-export type DataOptionSelection = z.infer;
+export type DataTurnResponse = z.infer;
export type DataConfirmation = z.infer;
export type DataPhaseSummary = z.infer;
@@ -57,7 +67,7 @@ export type BrunchMessageMetadata = {
export type BrunchDataParts = {
'observer-result': ObserverResultData;
- 'option-selection': DataOptionSelection;
+ 'turn-response': DataTurnResponse;
confirmation: DataConfirmation;
'phase-summary': DataPhaseSummary;
};
@@ -79,7 +89,7 @@ export type BrunchAssistantPart =
>;
export type BrunchUserPart = Extract<
BrunchUIMessagePart,
- { type: 'text' | 'data-option-selection' | 'data-confirmation' }
+ { type: 'text' | 'data-turn-response' | 'data-confirmation' }
>;
export type AskQuestionUIPart = Extract;
export type ObserverResultUIPart = Extract;
@@ -97,7 +107,7 @@ export const brunchValidationTools = {
export const brunchDataPartSchemas = {
'observer-result': observerResultSchema,
- 'option-selection': dataOptionSelectionSchema,
+ 'turn-response': dataTurnResponseSchema,
confirmation: dataConfirmationSchema,
'phase-summary': dataPhaseSummarySchema,
} as const;
@@ -108,7 +118,7 @@ const textPartSchema = z
text: z.string(),
state: z.enum(['streaming', 'done']).optional(),
})
- .passthrough();
+ .loose();
const reasoningPartSchema = z
.object({
@@ -116,13 +126,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 +140,7 @@ const observerResultPartSchema = z
id: z.string().optional(),
data: observerResultSchema,
})
- .passthrough();
+ .loose();
const phaseSummaryPartSchema = z
.object({
@@ -138,15 +148,15 @@ const phaseSummaryPartSchema = z
id: z.string().optional(),
data: dataPhaseSummarySchema,
})
- .passthrough();
+ .loose();
-const optionSelectionPartSchema = z
+const turnResponsePartSchema = z
.object({
- type: z.literal('data-option-selection'),
+ type: z.literal('data-turn-response'),
id: z.string().optional(),
- data: dataOptionSelectionSchema,
+ data: dataTurnResponseSchema,
})
- .passthrough();
+ .loose();
const confirmationPartSchema = z
.object({
@@ -154,7 +164,7 @@ const confirmationPartSchema = z
id: z.string().optional(),
data: dataConfirmationSchema,
})
- .passthrough();
+ .loose();
const approvalRequestedSchema = z.object({
id: z.string(),
@@ -173,7 +183,7 @@ const askQuestionToolBaseSchema = z
title: z.string().optional(),
providerExecuted: z.boolean().optional(),
})
- .passthrough();
+ .loose();
const askQuestionToolPartSchema = z.union([
askQuestionToolBaseSchema.extend({
@@ -229,7 +239,7 @@ export const assistantPartsSchema = z.array(
);
export const userPartsSchema = z.array(
- z.union([textPartSchema, optionSelectionPartSchema, confirmationPartSchema]),
+ z.union([textPartSchema, turnResponsePartSchema, confirmationPartSchema]),
);
export function isAskQuestionUIPart(part: BrunchUIMessagePart): part is AskQuestionUIPart {
@@ -247,6 +257,18 @@ export function extractTextFromMessage(message: Pick):
.join('');
}
+export function formatTurnResponseText({
+ selectedOptionContents,
+ freeText,
+}: {
+ selectedOptionContents: string[];
+ freeText?: string | null;
+}): string {
+ const trimmedFreeText = freeText?.trim();
+ const optionSummary = selectedOptionContents.join(', ');
+ return [optionSummary, trimmedFreeText].filter(Boolean).join(' — ');
+}
+
export function isToolOfType(
part: UIMessagePart,
toolName: NAME,
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}'],
+ },
});