From ea7a8e8711fcc503cebf987641bb14b926b99747 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 14:00:02 +0000 Subject: [PATCH 1/2] IMPLEMENT: use claude-haiku-4-5-20251001 for reconcile queries (closes #234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile:query was sharing currentModel (claude-sonnet-4-6) with the chat session. Haiku is sufficient for structured JSON extraction and significantly faster. Extracted buildReconcileSystemPrompt, buildReconcileUserTurn, and extractOpsArray into reconcile-query.ts with RECONCILE_MODEL = haiku constant. Files changed: - apps/desktop/src/main/ipc/reconcile-query.ts (new — pure helpers + model constant) - apps/desktop/src/main/ipc/claude.ts (import from reconcile-query, use RECONCILE_MODEL) - apps/desktop/tests/main/reconcile-query.test.ts (new — covers all pure helpers) --- apps/desktop/src/main/ipc/claude.ts | 150 +----------------- apps/desktop/src/main/ipc/reconcile-query.ts | 142 +++++++++++++++++ .../tests/main/reconcile-query.test.ts | 85 ++++++++++ 3 files changed, 234 insertions(+), 143 deletions(-) create mode 100644 apps/desktop/src/main/ipc/reconcile-query.ts create mode 100644 apps/desktop/tests/main/reconcile-query.test.ts diff --git a/apps/desktop/src/main/ipc/claude.ts b/apps/desktop/src/main/ipc/claude.ts index 2d05a9c..e9c4813 100644 --- a/apps/desktop/src/main/ipc/claude.ts +++ b/apps/desktop/src/main/ipc/claude.ts @@ -29,6 +29,12 @@ import { } from './claude-bridge'; import { ChatCancelledError } from './claude-errors'; import { invokeOpHandler } from './op-tool-bridge'; +import { + buildReconcileSystemPrompt, + buildReconcileUserTurn, + extractOpsArray, + RECONCILE_MODEL, +} from './reconcile-query'; const execFileAsync = promisify(execFile); @@ -360,7 +366,7 @@ export function registerClaudeIpc(mainWindow: BrowserWindow): ClaudeIpc { systemPrompt: buildReconcileSystemPrompt(payload.targetKind), allowedTools: [], disallowedTools: DISALLOWED_BUILTINS, - model: currentModel, + model: RECONCILE_MODEL, pathToClaudeCodeExecutable: detectedClaudePath ?? 'claude', ...(env ? { env } : {}), }, @@ -478,148 +484,6 @@ function toSdkTool(descriptor: OpToolDescriptor) { ); } -/** - * Target-kind descriptions used in the reconcile system prompt. - * These are lightly tuned per target so the model understands the - * output format it is reconciling against. - */ -const TARGET_KIND_DESCRIPTIONS: Record = { - convex: - 'a hand-edited `convex/schema.ts` file (Convex database schema). ' + - 'Focus on `defineTable`, `v.*` validators, and Convex index definitions.', - zod: - 'a hand-edited `.schema.ts` file (Zod schema mirror). ' + - 'Focus on `z.object`, `z.enum`, `z.discriminatedUnion`, and inferred TypeScript types.', - 'json-schema': - 'a hand-edited `.schema.json` file (JSON Schema). ' + - 'Focus on `$defs`, `properties`, `required`, `type`, and `enum` arrays.', - 'schema-index': - 'a hand-edited `index.ts` schema-index file that re-exports named Zod schemas. ' + - 'Focus on the set of exported names and their re-export paths.', -}; - -/** - * Build the reconcile system prompt for `reconcile:query`. Varies - * lightly by `targetKind` so the model understands which file format - * it is reconciling against. TypeScript-constructed (not a skill file) - * to keep the prompt static, cacheable, and decoupled from the - * chat-session prompt builder. - * - * The op-vocabulary list is duplicated from `system-prompt.ts` rather - * than imported because that module lives in the renderer's path - * (`@renderer/...`) and we want this file (which runs in the main - * process bundle) to keep its imports narrow. - */ -function buildReconcileSystemPrompt(targetKind: string): string { - const kindDesc = - TARGET_KIND_DESCRIPTIONS[targetKind] ?? - 'a hand-edited generated file that has diverged from what Contexture would emit.'; - - return `You are a schema reconciliation assistant for Contexture. - -The user has a Contexture IR (a JSON description of a schema) and ${kindDesc} -Your job is to return a JSON array of ops that, when applied to the IR, would make -Contexture emit an output as close as possible to the hand-edited file. - -## Op vocabulary - -- \`add_type\` { type: TypeDef } -- \`update_type\` { name: string; patch: Partial> } -- \`rename_type\` { from: string; to: string } -- \`delete_type\` { name: string } -- \`add_field\` { typeName: string; field: FieldDef; index?: number } -- \`update_field\` { typeName: string; fieldName: string; patch: Partial } -- \`remove_field\` { typeName: string; fieldName: string } -- \`reorder_fields\` { typeName: string; order: string[] } -- \`add_value\` { typeName: string; value: string; description?: string } -- \`update_value\` { typeName: string; value: string; patch: { value?: string; description?: string } } -- \`remove_value\` { typeName: string; value: string } -- \`add_variant\` { typeName: string; variant: string } -- \`set_discriminator\` { typeName: string; discriminator: string } -- \`add_import\` { import: ImportDecl } -- \`remove_import\` { alias: string } -- \`set_table_flag\` { typeName: string; table: boolean } -- \`add_index\` { typeName: string; index: { name: string; fields: string[] } } -- \`remove_index\` { typeName: string; name: string } -- \`update_index\` { typeName: string; name: string; patch: Partial<{ name: string; fields: string[] }> } -- \`replace_schema\` { schema: Schema } # escape hatch; full IR - -## FieldDef - -\`{ name: string; type: FieldType; optional?: boolean; nullable?: boolean; description?: string }\` - -## FieldType - -\`{ kind: 'string' | 'number' | 'boolean' | 'date' }\` (with optional constraints), -\`{ kind: 'literal'; value: string|number|boolean }\`, -\`{ kind: 'ref'; typeName: string }\`, -\`{ kind: 'array'; element: FieldType; min?: number; max?: number }\`. - -## Output format - -Return ONLY a JSON array — no prose, no markdown, no code fences. -Each element of the array MUST have this shape: - -\`\`\` -{ - "op": , - "label": "", - "lossy": -} -\`\`\` - -Mark \`lossy: true\` for: -- Deleting a type or field -- Renaming a field or type (data in the old column is lost unless migrated) -- Changing a field's type to an incompatible type - -If no ops are needed (the IR already produces the hand-edited file), return \`[]\`. - -The current IR and the hand-edited file are in the user message.`; -} - -function buildReconcileUserTurn(irJson: string, onDiskSource: string, targetKind: string): string { - return [ - '', - irJson, - '', - '', - ``, - onDiskSource, - '', - '', - 'Return the reconcile ops JSON array.', - ].join('\n'); -} - -/** - * Pull the JSON ops array out of an assistant response. The model is - * instructed to return only the array, but a stray code fence or - * leading sentence shouldn't fail the whole reconcile — match the - * outermost \`[…]\` block. - */ -function extractOpsArray( - text: string, -): { ok: true; ops: unknown[] } | { ok: false; error: string } { - const start = text.indexOf('['); - const end = text.lastIndexOf(']'); - if (start === -1 || end === -1 || end < start) { - return { ok: false, error: 'No JSON array found in response.' }; - } - const slice = text.slice(start, end + 1); - let parsed: unknown; - try { - parsed = JSON.parse(slice); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `Failed to parse ops JSON: ${message}` }; - } - if (!Array.isArray(parsed)) { - return { ok: false, error: 'Response did not parse as a JSON array.' }; - } - return { ok: true, ops: parsed }; -} - // Keep Zod in the surface area so downstream importers can build // matching schemas without separately importing it. export { z }; diff --git a/apps/desktop/src/main/ipc/reconcile-query.ts b/apps/desktop/src/main/ipc/reconcile-query.ts new file mode 100644 index 0000000..81fa58b --- /dev/null +++ b/apps/desktop/src/main/ipc/reconcile-query.ts @@ -0,0 +1,142 @@ +/** + * Pure helpers for the `reconcile:query` IPC handler. + * + * Extracted to a separate module so they can be unit-tested without + * wiring up Electron's ipcMain or the Agent SDK. + */ + +/** Dedicated model for reconcile queries — Haiku is fast enough for structured JSON extraction. */ +export const RECONCILE_MODEL = 'claude-haiku-4-5-20251001' as const; + +/** + * Target-kind descriptions used in the reconcile system prompt. + * Lightly tuned per target so the model understands the output format. + */ +const TARGET_KIND_DESCRIPTIONS: Record = { + convex: + 'a hand-edited `convex/schema.ts` file (Convex database schema). ' + + 'Focus on `defineTable`, `v.*` validators, and Convex index definitions.', + zod: + 'a hand-edited `.schema.ts` file (Zod schema mirror). ' + + 'Focus on `z.object`, `z.enum`, `z.discriminatedUnion`, and inferred TypeScript types.', + 'json-schema': + 'a hand-edited `.schema.json` file (JSON Schema). ' + + 'Focus on `$defs`, `properties`, `required`, `type`, and `enum` arrays.', + 'schema-index': + 'a hand-edited `index.ts` schema-index file that re-exports named Zod schemas. ' + + 'Focus on the set of exported names and their re-export paths.', +}; + +export function buildReconcileSystemPrompt(targetKind: string): string { + const kindDesc = + TARGET_KIND_DESCRIPTIONS[targetKind] ?? + 'a hand-edited generated file that has diverged from what Contexture would emit.'; + + return `You are a schema reconciliation assistant for Contexture. + +The user has a Contexture IR (a JSON description of a schema) and ${kindDesc} +Your job is to return a JSON array of ops that, when applied to the IR, would make +Contexture emit an output as close as possible to the hand-edited file. + +## Op vocabulary + +- \`add_type\` { type: TypeDef } +- \`update_type\` { name: string; patch: Partial> } +- \`rename_type\` { from: string; to: string } +- \`delete_type\` { name: string } +- \`add_field\` { typeName: string; field: FieldDef; index?: number } +- \`update_field\` { typeName: string; fieldName: string; patch: Partial } +- \`remove_field\` { typeName: string; fieldName: string } +- \`reorder_fields\` { typeName: string; order: string[] } +- \`add_value\` { typeName: string; value: string; description?: string } +- \`update_value\` { typeName: string; value: string; patch: { value?: string; description?: string } } +- \`remove_value\` { typeName: string; value: string } +- \`add_variant\` { typeName: string; variant: string } +- \`set_discriminator\` { typeName: string; discriminator: string } +- \`add_import\` { import: ImportDecl } +- \`remove_import\` { alias: string } +- \`set_table_flag\` { typeName: string; table: boolean } +- \`add_index\` { typeName: string; index: { name: string; fields: string[] } } +- \`remove_index\` { typeName: string; name: string } +- \`update_index\` { typeName: string; name: string; patch: Partial<{ name: string; fields: string[] }> } +- \`replace_schema\` { schema: Schema } # escape hatch; full IR + +## FieldDef + +\`{ name: string; type: FieldType; optional?: boolean; nullable?: boolean; description?: string }\` + +## FieldType + +\`{ kind: 'string' | 'number' | 'boolean' | 'date' }\` (with optional constraints), +\`{ kind: 'literal'; value: string|number|boolean }\`, +\`{ kind: 'ref'; typeName: string }\`, +\`{ kind: 'array'; element: FieldType; min?: number; max?: number }\`. + +## Output format + +Return ONLY a JSON array — no prose, no markdown, no code fences. +Each element of the array MUST have this shape: + +\`\`\` +{ + "op": , + "label": "", + "lossy": +} +\`\`\` + +Mark \`lossy: true\` for: +- Deleting a type or field +- Renaming a field or type (data in the old column is lost unless migrated) +- Changing a field's type to an incompatible type + +If no ops are needed (the IR already produces the hand-edited file), return \`[]\`. + +The current IR and the hand-edited file are in the user message.`; +} + +export function buildReconcileUserTurn( + irJson: string, + onDiskSource: string, + targetKind: string, +): string { + return [ + '', + irJson, + '', + '', + ``, + onDiskSource, + '', + '', + 'Return the reconcile ops JSON array.', + ].join('\n'); +} + +/** + * Pull the JSON ops array out of an assistant response. The model is + * instructed to return only the array, but a stray code fence or + * leading sentence shouldn't fail the whole reconcile — match the + * outermost `[…]` block. + */ +export function extractOpsArray( + text: string, +): { ok: true; ops: unknown[] } | { ok: false; error: string } { + const start = text.indexOf('['); + const end = text.lastIndexOf(']'); + if (start === -1 || end === -1 || end < start) { + return { ok: false, error: 'No JSON array found in response.' }; + } + const slice = text.slice(start, end + 1); + let parsed: unknown; + try { + parsed = JSON.parse(slice); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Failed to parse ops JSON: ${message}` }; + } + if (!Array.isArray(parsed)) { + return { ok: false, error: 'Response did not parse as a JSON array.' }; + } + return { ok: true, ops: parsed }; +} diff --git a/apps/desktop/tests/main/reconcile-query.test.ts b/apps/desktop/tests/main/reconcile-query.test.ts new file mode 100644 index 0000000..f208750 --- /dev/null +++ b/apps/desktop/tests/main/reconcile-query.test.ts @@ -0,0 +1,85 @@ +import { + buildReconcileSystemPrompt, + buildReconcileUserTurn, + extractOpsArray, + RECONCILE_MODEL, +} from '@main/ipc/reconcile-query'; +import { describe, expect, it } from 'vitest'; + +describe('RECONCILE_MODEL', () => { + it('is claude-haiku-4-5-20251001 for fast reconcile queries', () => { + expect(RECONCILE_MODEL).toBe('claude-haiku-4-5-20251001'); + }); +}); + +describe('extractOpsArray', () => { + it('parses a bare JSON array', () => { + const result = extractOpsArray('[{"op":{"kind":"add_type"},"label":"Add Post","lossy":false}]'); + expect(result).toEqual({ + ok: true, + ops: [{ op: { kind: 'add_type' }, label: 'Add Post', lossy: false }], + }); + }); + + it('extracts array embedded in prose', () => { + const result = extractOpsArray('Here are the ops:\n[{"op":{},"label":"x","lossy":true}]'); + expect(result.ok).toBe(true); + }); + + it('returns ok:false when no array bracket found', () => { + const result = extractOpsArray('no ops here'); + expect(result).toEqual({ ok: false, error: 'No JSON array found in response.' }); + }); + + it('returns ok:false for malformed JSON', () => { + const result = extractOpsArray('[{bad json}]'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/Failed to parse ops JSON/); + } + }); + + it('handles empty array', () => { + const result = extractOpsArray('[]'); + expect(result).toEqual({ ok: true, ops: [] }); + }); +}); + +describe('buildReconcileSystemPrompt', () => { + it('includes the op vocabulary', () => { + const prompt = buildReconcileSystemPrompt('zod'); + expect(prompt).toContain('add_type'); + expect(prompt).toContain('add_field'); + }); + + it('includes zod-specific kind description', () => { + const prompt = buildReconcileSystemPrompt('zod'); + expect(prompt).toContain('Zod'); + }); + + it('includes convex-specific kind description', () => { + const prompt = buildReconcileSystemPrompt('convex'); + expect(prompt).toContain('Convex'); + }); + + it('falls back gracefully for unknown target kinds', () => { + const prompt = buildReconcileSystemPrompt('unknown-kind'); + expect(prompt).toContain('hand-edited generated file'); + }); + + it('instructs the model to return only a JSON array', () => { + const prompt = buildReconcileSystemPrompt('zod'); + expect(prompt).toContain('Return ONLY a JSON array'); + }); +}); + +describe('buildReconcileUserTurn', () => { + it('wraps IR and on-disk source in XML tags', () => { + const turn = buildReconcileUserTurn('{"types":[]}', 'const x = 1;', 'zod'); + expect(turn).toContain(''); + expect(turn).toContain('{"types":[]}'); + expect(turn).toContain(''); + expect(turn).toContain(''); + expect(turn).toContain('const x = 1;'); + }); +}); From f5a2eb5653fe96fd6118cf83ad312327d17b2c75 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 14:08:49 +0000 Subject: [PATCH 2/2] REVIEW: fix extractOpsArray bracket scanning, restore ChatPanel CSS auto-grow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractOpsArray: scan all `[` positions instead of using the first — prose containing square brackets before the actual JSON array would cause indexOf to pick the wrong start, failing a valid response; added a test covering that case - ChatPanel scroll effect: restore the messageCount guard pattern (`if (messageCount === 0) return`) so messageCount is referenced in the effect body and no biome-ignore is required - ChatPanel textarea: revert JS auto-grow effect (empty deps, only ran on mount so the textarea never resized while typing) back to the original CSS field-sizing-content approach; restore the deleted test that verified the CSS contract --- apps/desktop/src/main/ipc/reconcile-query.ts | 37 ++++++++++--------- .../src/components/chat/ChatPanel.tsx | 21 +++-------- .../tests/components/chat/ChatPanel.test.tsx | 16 ++++++++ .../tests/main/reconcile-query.test.ts | 10 +++++ 4 files changed, 52 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/ipc/reconcile-query.ts b/apps/desktop/src/main/ipc/reconcile-query.ts index 81fa58b..151072d 100644 --- a/apps/desktop/src/main/ipc/reconcile-query.ts +++ b/apps/desktop/src/main/ipc/reconcile-query.ts @@ -116,27 +116,30 @@ export function buildReconcileUserTurn( /** * Pull the JSON ops array out of an assistant response. The model is * instructed to return only the array, but a stray code fence or - * leading sentence shouldn't fail the whole reconcile — match the - * outermost `[…]` block. + * leading sentence shouldn't fail the whole reconcile — scan forward + * through every `[` position until we find a slice that parses as an + * array. This handles prose that contains `[…]` spans before the + * actual payload. */ export function extractOpsArray( text: string, ): { ok: true; ops: unknown[] } | { ok: false; error: string } { - const start = text.indexOf('['); const end = text.lastIndexOf(']'); - if (start === -1 || end === -1 || end < start) { - return { ok: false, error: 'No JSON array found in response.' }; + let start = text.indexOf('['); + let lastError = 'No JSON array found in response.'; + + while (start !== -1 && start <= end) { + const slice = text.slice(start, end + 1); + try { + const parsed = JSON.parse(slice); + if (Array.isArray(parsed)) return { ok: true, ops: parsed }; + lastError = 'Response did not parse as a JSON array.'; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + lastError = `Failed to parse ops JSON: ${message}`; + } + start = text.indexOf('[', start + 1); } - const slice = text.slice(start, end + 1); - let parsed: unknown; - try { - parsed = JSON.parse(slice); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `Failed to parse ops JSON: ${message}` }; - } - if (!Array.isArray(parsed)) { - return { ok: false, error: 'Response did not parse as a JSON array.' }; - } - return { ok: true, ops: parsed }; + + return { ok: false, error: lastError }; } diff --git a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx index 05708d3..c36ab7d 100644 --- a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx +++ b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx @@ -73,7 +73,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { const history = useChatThreads(); const [input, setInput] = useState(''); - const textareaRef = useRef(null); const messagesEndRef = useRef(null); // Track previous filePath so file-change and initial-mount paths @@ -161,19 +160,11 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { }); }, [history.activeThreadId, history.updateThreadSessionId]); - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever the message list changes; biome can't see `messages` is the intended trigger + const messageCount = messages.length; useEffect(() => { + if (messageCount === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - // Auto-grow the textarea up to a cap so prompts with a few lines - // don't force the user to scroll a single-line input. - useEffect(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = 'auto'; - el.style.height = `${Math.min(el.scrollHeight, 160)}px`; - }, []); + }, [messageCount]); const fileThreads = useMemo( () => history.threadsForFile(filePath), @@ -367,7 +358,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { )} >