diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0c213c4444..0e168ba9fe 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -124,3 +124,10 @@ export function mountAssembledApp(): void { export function hasClass(el: Element, name: string): boolean { return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) } + +/** + * Whether this run rewrites its golden instead of comparing against it, set by + * the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from + * scratch, `refresh` re-derives the expected text from the existing ones). + */ +export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 745b545293..626be993a6 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -18,10 +18,9 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') -const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' installAssembledBootEnv() @@ -72,7 +71,7 @@ describe('assembled search card', () => { expect(grepRow.querySelector('[data-search]')).not.toBeNull() }, { timeout: 10_000 }) const shape = cardShape(grepRow) - if (refreshing) { + if (REFRESHING_GOLDEN) { mkdirSync(dirname(EXPECTED), { recursive: true }) writeFileSync(EXPECTED, shape) } diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index 70fd95c447..c05057dddc 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -14,10 +14,9 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt') -const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' installAssembledBootEnv() @@ -63,7 +62,7 @@ describe('assembled todo surfaces', () => { if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) const shape = todoShape(row, panel) - if (refreshing) { + if (REFRESHING_GOLDEN) { mkdirSync(dirname(EXPECTED), { recursive: true }) writeFileSync(EXPECTED, shape) } diff --git a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts index 6fc37d8c28..dd4fd67be1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts +++ b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts @@ -38,17 +38,19 @@ export interface PlanSummary { * the first `in_progress` item and counts the remaining active ones, so a * parallel plan reports how many tasks are running rather than naming one and * hiding the others. `activeContent` is null when nothing is in progress, or - * when the first active item carries no usable content — model JSON may. The - * row then renders the counts alone rather than falling back to the generic - * tool summary: the counts are already known to be good, and the active-item - * clause is the only part an unusable name costs. + * when the first active item's content is missing, mistyped, or blank once + * trimmed — the tool's own rule for usable content, applied here because a + * rejected call keeps its args verbatim. The row then renders the counts alone + * rather than falling back to the generic tool summary: the counts are already + * known to be good, and the active-item clause is the only part an unusable + * name costs. * @param todos - the whole list, in model order. * @returns the done/total counts and the two summary halves. */ export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { const active = todos.filter(t => t.status === 'in_progress') const first = active[0]?.content - const named = typeof first === 'string' && first !== '' + const named = typeof first === 'string' && first.trim() !== '' return { done: todos.filter(t => t.status === 'completed').length, total: todos.length, diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 57149c9195..3ab95168a7 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -62,12 +62,16 @@ describe('planSummary', () => { }) it('has no hint when the first active item carries no usable content (model JSON)', () => { - // Unvalidated args: a missing, mistyped, or empty content yields no hint — - // and no orphan count, even with a second active item to count. + // Unvalidated args: a missing, mistyped, empty, or whitespace-only content + // yields no hint — and no orphan count, even with a second active item to + // count. Whitespace-only is the tool's own rejection rule (trimmed + // non-empty), and a rejected call keeps its args verbatim. expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) .toMatchObject({ activeContent: null, activeExtra: 0 }) expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull() expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull() + expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) + .toMatchObject({ activeContent: null, activeExtra: 0 }) }) it('is empty-safe', () => {