From f4f3e63bf3ead73b822ca5a3abfe07c946df8ca1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 15:24:47 -0700 Subject: [PATCH 1/6] chore(tooling): pre-commit hook lints staged files Lint failures were the most common PR CI failure while the hook only formatted. The hook now runs lint after format on staged files. Co-Authored-By: Claude Opus 5.5 --- vite.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 909becac31779..ea4bbf1d2356c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,7 +149,8 @@ export default defineConfig({ : {}), }, staged: { - "*": "vp fmt --no-error-on-unmatched-pattern", + // In order, so lint reads the formatted file. CI lint failures cost a full round trip. + "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], }, fmt: { ignorePatterns: [ From 537681321f123229bc6cacd0a621513ce32b7037 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 18:14:29 -0700 Subject: [PATCH 2/6] chore(tooling): check:changed runs the cross-file checks it missed Co-Authored-By: Claude Opus 5.5 --- scripts/check-changed.ts | 103 ++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 25630728e3d66..b854cf0a4e539 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off // Runs CI's fast checks for this branch's changes: format and lint of the // changed files, knip, generated GraphQL operations, typecheck of the affected -// packages and their dependents, and the test files beside the changed files. +// packages and their dependents, the test files beside the changed files, and +// the cross-file checks those miss (agent context budget, React Compiler +// coverage of the changed client files). // A dependency change also gets the unused-patch install check below. CI still // runs the full test suites and the jobs listed in the summary. // @@ -138,6 +140,52 @@ for (const file of code) { if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(NodePath.join(dir, entry)); } } +// It reads files other than the ones beside it, so no changed file selects it. +if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts"); + +// React Compiler coverage compiles every client source file, so run only the +// changed files' cases, in one extra step: a -t filter in the main test step +// would drop every other test. An app is skipped when the main step already +// runs its coverage file unfiltered. File filters mirror each coverage test. +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const compilerApps = [ + { + test: "apps/web/src/reactCompilerCoverage.test.ts", + root: "apps/web/src/", + // Test names keep the repo-relative path. + name: (file: string) => file, + compiled: (file: string) => + /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file) && !file.endsWith(".gen.ts"), + // Web has only per-file cases. + always: [], + }, + { + test: "apps/mobile/src/reactCompilerCoverage.test.ts", + root: "apps/mobile/src/", + name: (file: string) => file.slice("apps/mobile/".length), + compiled: (file: string) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file), + // The Metro, cache, and stale-allowlist cases check more than one file, deletions included. + always: ["^(?!.*compiles )"], + }, +].flatMap(({ test, root: appRoot, name, compiled, always }) => { + const changed = files.filter((file) => file.startsWith(appRoot) && compiled(file)); + if (changed.length === 0 || tests.has(test)) return []; + const names = changed + .filter((file) => present.includes(file)) + .map((file) => `compiles ${escapeRegExp(name(file))}$`); + const pattern = [...names, ...always]; + return pattern.length > 0 ? [{ test, names, pattern }] : []; +}); +const compilerCoverage = compilerApps.length > 0 && { + name: `react compiler (${compilerApps.reduce((count, app) => count + app.names.length, 0)} files)`, + args: [ + "test", + "run", + ...compilerApps.map((app) => app.test), + "-t", + compilerApps.flatMap((app) => app.pattern).join("|"), + ], +}; // knip:check cannot be scoped to a package. Any code change can leave a file, // export, or dependency unused (removing the last import of a helper does), so @@ -255,33 +303,42 @@ const steps = [ name: `test (${tests.size} files)`, args: ["test", "run", "--passWithNoTests", ...tests], }, + compilerCoverage, ].filter((step) => step !== false); // Output is piped, so keep the colors a terminal would get. const env = process.stdout.isTTY ? { ...process.env, FORCE_COLOR: "1" } : process.env; +const runStep = (step: (typeof steps)[number]) => + new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { + const started = performance.now(); + const finish = (ok: boolean, output?: Buffer) => { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); + if (output) process.stdout.write(output); + resolve({ name: step.name, ok, seconds }); + }; + // In-process steps print their own output. The patch check is first and + // takes about a second, so the commands start right after it. + if ("run" in step) return finish(step.run()); + const output: Array = []; + const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + // A failed spawn emits "error", then "close" with a nonzero code. + child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); + child.on("close", (status) => finish(status === 0, Buffer.concat(output))); + }); + +// Vitest runs started together race to rebuild node_modules/.vitest-cache after +// an install (ENOTEMPTY), so they run one after another. +let previousVitest: Promise = Promise.resolve(); const results = await Promise.all( - steps.map( - (step) => - new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { - const started = performance.now(); - const finish = (ok: boolean, output?: Buffer) => { - const seconds = ((performance.now() - started) / 1000).toFixed(1); - console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); - if (output) process.stdout.write(output); - resolve({ name: step.name, ok, seconds }); - }; - // In-process steps print their own output. The patch check is first and - // takes about a second, so the commands start right after it. - if ("run" in step) return finish(step.run()); - const output: Array = []; - const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); - child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); - // A failed spawn emits "error", then "close" with a nonzero code. - child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); - child.on("close", (status) => finish(status === 0, Buffer.concat(output))); - }), - ), + steps.map((step) => { + if (!("args" in step) || step.args[0] !== "test") return runStep(step); + const result = previousVitest.then(() => runStep(step)); + previousVitest = result; + return result; + }), ); const summary = ["check:changed summary"]; From 85fc928e83740280a6cca1d781837878c5baf13e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 15:35:28 -0700 Subject: [PATCH 3/6] refactor(client): one copy of pending-input answer rules Web and mobile each had their own rules for turning question drafts into answers, and the two had drifted. The rules now live in client-runtime, taken from mobile's stricter copy, and both clients import them directly. Web's progress helpers use the shared normalizer. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/PendingUserInputCard.tsx | 4 +- .../features/threads/ThreadDetailScreen.tsx | 2 +- apps/mobile/src/lib/threadActivity.test.ts | 104 ---------- apps/mobile/src/lib/threadActivity.ts | 162 ---------------- .../src/state/use-selected-thread-requests.ts | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../chat/ComposerPendingUserInputPanel.tsx | 6 +- .../chat/usePendingThreadRequests.ts | 10 +- apps/web/src/pendingUserInput.test.ts | 156 ++------------- apps/web/src/pendingUserInput.ts | 130 ++----------- packages/client-runtime/package.json | 4 + .../src/state/pendingUserInput.test.ts | 180 ++++++++++++++++++ .../src/state/pendingUserInput.ts | 176 +++++++++++++++++ 13 files changed, 396 insertions(+), 542 deletions(-) create mode 100644 packages/client-runtime/src/state/pendingUserInput.test.ts create mode 100644 packages/client-runtime/src/state/pendingUserInput.ts diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index d097416b2ef20..9ecbbb70f1d8e 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -26,9 +26,9 @@ import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, - type PendingUserInput, type PendingUserInputDraftAnswer, -} from "../../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; +import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a23f06abef24b..27655f503bd65 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -157,10 +157,10 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { PendingApproval, PendingUserInput, - PendingUserInputDraftAnswer, ThreadFeedEntry, ThreadFeedLatestRun, } from "../../lib/threadActivity"; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5060f359ebcd3..acdee2249c0d0 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -31,10 +31,6 @@ import { type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, - togglePendingUserInputOptionSelection, - setPendingUserInputCustomAnswer, - isPendingUserInputOptionSelected, - buildPendingUserInputAnswers, } from "./threadActivity"; const threadId = ThreadId.make("thread-1"); @@ -1368,17 +1364,6 @@ describe("retained v2 feed presentation", () => { }); }); -const singleSelectQuestion = { - id: "runtime", - header: "Runtime", - question: "Which runtime should be used?", - options: [ - { label: "Go", description: "One binary" }, - { label: "Node.js", description: "Reuse TypeScript" }, - ], - multiSelect: false, -} as const; - describe("subagent continued by a later turn", () => { const run2 = RunId.make("run-2"); const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; @@ -1454,95 +1439,6 @@ describe("subagent continued by a later turn", () => { }); }); -describe("pending user input answers", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: question.initialAnswer }, - }), - ).toEqual({ runtime: question.initialAnswer }); - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: " Edited message\n\n" }, - }), - ).toEqual({ runtime: " Edited message\n\n" }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ - runtime: "", - }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ - runtime: " \n", - }); - expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( - { customAnswer: "" }, - ); - }); -}); - -describe("provider question values", () => { - const question = { - ...singleSelectQuestion, - allowCustomAnswer: false, - options: [ - { label: "Same label", value: " exact first ", description: "First" }, - { label: "Same label", value: "second", description: "Second" }, - ], - } as const; - - it("submits raw option values and distinguishes duplicate labels", () => { - const first = togglePendingUserInputOptionSelection(question, undefined, " exact first "); - expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true); - expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false); - expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({ - runtime: " exact first ", - }); - expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first); - }); - - it("rejects arbitrary text when the provider only accepts offered options", () => { - expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); - expect( - buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { - runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, - }), - ).toEqual({ runtime: "second" }); - }); -}); - -it("accepts ready attachment-only answers while preserving selected options", () => { - const question = { - id: "q", - header: "Spec", - question: "Provide a specification", - options: [{ label: "Yes", description: "Approve" }], - multiSelect: false, - }; - expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({ - q: "", - }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, selectedOptionValues: ["Yes"] }, - }), - ).toEqual({ q: "Yes" }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], { - q: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fc085a7c50a0d..e17748d77aa6c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,7 +1,6 @@ import type { ThreadPendingApproval, ThreadPendingUserInput, - ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; @@ -81,13 +80,6 @@ import { export type PendingApproval = ThreadPendingApproval; export type PendingUserInput = ThreadPendingUserInput; -export interface PendingUserInputDraftAnswer { - readonly selectedOptionValues?: ReadonlyArray; - readonly customAnswer?: string; - readonly attachmentCount?: number; - readonly attachmentsBlocked?: boolean; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -406,73 +398,6 @@ function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function resolvePendingUserInputOptionValue( - question: ThreadUserInputQuestion, - value: string, -): string | null { - if (question.options.some((option) => option.value === value)) { - return value; - } - - const label = value.trim(); - return label.length > 0 && - question.options.some((option) => option.value === undefined && option.label.trim() === label) - ? label - : null; -} - -function normalizeSelectedOptionValues( - question: ThreadUserInputQuestion, - value: ReadonlyArray | undefined, -): ReadonlyArray { - if (!Array.isArray(value)) { - return []; - } - - return Array.from( - new Set( - value - .map((entry) => resolvePendingUserInputOptionValue(question, entry)) - .filter((entry): entry is string => entry !== null), - ), - ); -} - -function resolvePendingUserInputAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | ReadonlyArray | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - function capitalizePhrase(value: string): string { const trimmed = value.trim(); return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; @@ -1974,93 +1899,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo return activity.detail ?? activity.summary; } -export function setPendingUserInputCustomAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, -): PendingUserInputDraftAnswer { - if (question.allowCustomAnswer === false) { - return draft ?? {}; - } - - const selectedOptionValues = - question.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - -export function isPendingUserInputOptionSelected( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): boolean { - if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { - return false; - } - - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - return ( - resolvedOptionValue !== null && - normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( - resolvedOptionValue, - ) - ); -} - -export function togglePendingUserInputOptionSelection( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - if (resolvedOptionValue === null) { - return draft ?? {}; - } - - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues( - question, - draft?.selectedOptionValues, - ); - const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) - ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) - : [...selectedOptionValues, resolvedOptionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [resolvedOptionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record> | null { - const answers: Record> = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - interface ThreadFeedBuild { readonly rows: ReadonlyArray; readonly attempts: ReadonlyArray | undefined; diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index f869719601750..d4bbc6bfe5bc0 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -42,7 +42,7 @@ import { setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, -} from "../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadPendingRequests } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57838e598161e..d7878cc29a165 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -241,7 +241,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 697a0e0bfd6f2..e3e14c4a84005 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,10 +3,8 @@ import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 35e4cc5f08551..91c5bd0d6c912 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -27,13 +27,15 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; import { buildPendingUserInputAnswers, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; +import { carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, - type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; import { questionAttachmentDraftId, @@ -98,9 +100,9 @@ function makePendingCustomAnswerSetter(input: { [activePendingRequestKey]: { ...existing[activePendingRequestKey], [questionId]: setPendingUserInputCustomAnswer( + question, existing[activePendingRequestKey]?.[questionId], value, - question, ), }, })); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 69800be4b7574..1178c91043052 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,14 +5,10 @@ import { threadContextReference } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { - buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - resolvePendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, } from "./pendingUserInput"; describe("projectPendingUserInputAnswer", () => { @@ -68,128 +64,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const nativeChoiceQuestion = { - id: "result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, -} as const; - -describe("resolvePendingUserInputAnswer", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( - question.initialAnswer, - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( - " Edited message\n\n", - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); - expect( - setPendingUserInputCustomAnswer( - { selectedOptionValues: ["Orchestration-first"] }, - "", - question, - ), - ).toEqual({ customAnswer: "" }); - const drafts = { scope: { customAnswer: "" } }; - expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ - customAnswer: "", - resolvedAnswer: "", - canAdvance: true, - isComplete: true, - }); - }); - - it("prefers a custom answer over selected options", () => { - expect( - resolvePendingUserInputAnswer(singleSelectQuestion, { - selectedOptionValues: ["Orchestration-first"], - customAnswer: "Keep the existing envelope for one release", - }), - ).toBe("Keep the existing envelope for one release"); - }); - - it("does not replace a required choice with a custom answer", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["second"], - customAnswer: "Use another result", - }), - ).toBe("second"); - }); - - it("does not submit labels or unknown values when an option has a value", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["Result", "unknown"], - }), - ).toBeNull(); - }); -}); - -describe("togglePendingUserInputOptionSelection", () => { - it("selects and removes options with the same label independently", () => { - const question = { ...nativeChoiceQuestion, multiSelect: true }; - const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); - const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); - - expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ - result: [" first\t", "second"], - }); - - const secondSelected = togglePendingUserInputOptionSelection( - question, - bothSelected, - " first\t", - ); - expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ - result: ["second"], - }); - }); -}); - -describe("buildPendingUserInputAnswers", () => { - it("returns a canonical answer map for complete prompts", () => { - expect( - buildPendingUserInputAnswers( - [ - singleSelectQuestion, - { - id: "compat", - header: "Compat", - question: "How strict should compatibility be?", - options: [ - { - label: "Keep current envelope", - description: "Preserve current wire format", - }, - ], - multiSelect: false, - }, - ], - { - scope: { - selectedOptionValues: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep the current envelope for one release window", - }, - }, - ), - ).toEqual({ - scope: "Orchestration-first", - compat: "Keep the current envelope for one release window", - }); - }); -}); - describe("pending user input question progress", () => { const questions = [ singleSelectQuestion, @@ -207,6 +81,17 @@ describe("pending user input question progress", () => { }, ] as const; + it("counts a deliberately cleared answer as answered", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + const drafts = { scope: { customAnswer: "" } }; + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( @@ -232,25 +117,6 @@ describe("pending user input question progress", () => { }); }); -it("accepts attachment-only answers after every upload finishes", () => { - const questions = [ - { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, - ]; - expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ - spec: "", - }); - expect( - buildPendingUserInputAnswers(questions, { - spec: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { - spec: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - describe("carryDisplacedCustomAnswerIntoPrompt", () => { // regression: #12577 clicking a question option discarded typed text it("keeps the thread draft when nothing was typed into the answer", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f8f9d6d52b5ba..17998eaf8010c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { + buildPendingUserInputAnswers, + normalizeSelectedOptionValues, + resolvePendingUserInputAnswer, + type PendingUserInputAnswer, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; import { collectInlineContextIds } from "./lib/composerContextReferences"; @@ -25,20 +32,13 @@ export function threadContextsReferencedByAnswer( return threadContexts.filter((record) => referencedIds.has(record.contextId)); } -export interface PendingUserInputDraftAnswer { - selectedOptionValues?: string[]; - customAnswer?: string; - attachmentCount?: number; - attachmentsBlocked?: boolean; -} - export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; - selectedOptionValues: string[]; + selectedOptionValues: ReadonlyArray; customAnswer: string; - resolvedAnswer: string | string[] | null; + resolvedAnswer: PendingUserInputAnswer | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; @@ -46,72 +46,6 @@ export interface PendingUserInputProgress { canAdvance: boolean; } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeSelectedOptionValues(value: string[] | undefined): string[] { - if (!Array.isArray(value)) { - return []; - } - - // Provider option IDs must stay unchanged, including whitespace. - return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); -} - -export function resolvePendingUserInputAnswer( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | string[] | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), - ); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - -export function setPendingUserInputCustomAnswer( - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, - question?: Pick, -): PendingUserInputDraftAnswer { - const selectedOptionValues = - question?.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(draft?.selectedOptionValues); - - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( @@ -128,48 +62,6 @@ export function carryDisplacedCustomAnswerIntoPrompt( return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; } -export function togglePendingUserInputOptionSelection( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) - ? selectedOptionValues.filter((value) => value !== optionValue) - : [...selectedOptionValues, optionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [optionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record | null { - const answers: Record = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, @@ -203,7 +95,9 @@ export function derivePendingUserInputProgress( questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, - selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), + selectedOptionValues: activeQuestion + ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues) + : [], customAnswer, resolvedAnswer, usingCustomAnswer: diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 66e79c7492ae3..5808641a5606a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -331,6 +331,10 @@ "types": "./src/state/threadExecution.ts", "default": "./src/state/threadExecution.ts" }, + "./state/pending-user-input": { + "types": "./src/state/pendingUserInput.ts", + "default": "./src/state/pendingUserInput.ts" + }, "./state/thread-requests": { "types": "./src/state/threadRequests.ts", "default": "./src/state/threadRequests.ts" diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts new file mode 100644 index 0000000000000..7f5a2b0b84c3d --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPendingUserInputAnswers, + isPendingUserInputOptionSelected, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, +} from "./pendingUserInput.ts"; + +const singleSelectQuestion = { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { label: "Orchestration-first", description: "Focus on orchestration first" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const nativeChoiceQuestion = { + id: "result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, +} as const; + +describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + question, + { selectedOptionValues: ["Orchestration-first"] }, + "", + ), + ).toEqual({ customAnswer: "" }); + }); + + it("prefers a custom answer over selected options", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + selectedOptionValues: ["Orchestration-first"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("does not replace a required choice with a custom answer", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["second"], + customAnswer: "Use another result", + }), + ).toBe("second"); + }); + + it("does not submit labels or unknown values when an option has a value", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["Result", "unknown"], + }), + ).toBeNull(); + }); + + it("matches a label only for an option without a value", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionValues: [" Node.js "] }), + ).toBe("Node.js"); + }); + + it("accepts ready attachment-only answers while preserving selected options", () => { + expect(resolvePendingUserInputAnswer(singleSelectQuestion, { attachmentCount: 1 })).toBe(""); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + selectedOptionValues: ["Node.js"], + }), + ).toBe("Node.js"); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + attachmentsBlocked: true, + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer( + { ...singleSelectQuestion, allowCustomAnswer: false }, + { attachmentCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("togglePendingUserInputOptionSelection", () => { + it("selects and removes options with the same label independently", () => { + const question = { ...nativeChoiceQuestion, multiSelect: true }; + const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); + const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); + + expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ + result: [" first\t", "second"], + }); + + const secondSelected = togglePendingUserInputOptionSelection( + question, + bothSelected, + " first\t", + ); + expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ + result: ["second"], + }); + }); + + it("submits raw option values and leaves the draft alone for an unknown option", () => { + const first = togglePendingUserInputOptionSelection( + nativeChoiceQuestion, + undefined, + " first\t", + ); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, " first\t")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, "second")).toBe(false); + expect(buildPendingUserInputAnswers([nativeChoiceQuestion], { result: first })).toEqual({ + result: " first\t", + }); + expect(togglePendingUserInputOptionSelection(nativeChoiceQuestion, first, "Result")).toBe( + first, + ); + }); +}); + +describe("setPendingUserInputCustomAnswer", () => { + it("rejects arbitrary text when the provider only accepts offered options", () => { + expect(setPendingUserInputCustomAnswer(nativeChoiceQuestion, undefined, "Other")).toEqual({}); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { result: { customAnswer: "Other" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { + result: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + }), + ).toEqual({ result: "second" }); + }); +}); + +const compatQuestion = { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [{ label: "Keep current envelope", description: "Preserve current wire format" }], + multiSelect: false, +} as const; + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], { + scope: { selectedOptionValues: ["Orchestration-first"] }, + compat: { customAnswer: "Keep the current envelope for one release window" }, + }), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts new file mode 100644 index 0000000000000..1c71cdb29b4df --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -0,0 +1,176 @@ +/** The question fields answer handling reads. Web and mobile question types both fit it. */ +export interface PendingUserInputQuestion { + readonly id: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>; + readonly multiSelect?: boolean | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly initialAnswer?: string | undefined; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionValues?: ReadonlyArray; + readonly customAnswer?: string; + readonly attachmentCount?: number; + readonly attachmentsBlocked?: boolean; +} + +export type PendingUserInputAnswer = string | ReadonlyArray; + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// Provider option values stay exact, including whitespace. Only options without a value match by label. +function resolvePendingUserInputOptionValue( + question: PendingUserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +/** The draft's selections that name an offered option, deduplicated, in selection order. */ +export function normalizeSelectedOptionValues( + question: PendingUserInputQuestion, + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), + ); +} + +/** The answer to submit for one question, or null while it is unanswered. */ +export function resolvePendingUserInputAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): PendingUserInputAnswer | null { + if (draft?.attachmentsBlocked) return null; + const customAnswer = + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { + return customAnswer; + } + + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + if (question.multiSelect) { + return selectedOptionValues.length > 0 + ? selectedOptionValues + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); + } + return ( + selectedOptionValues[0] ?? + customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) + ); +} + +export function setPendingUserInputCustomAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = + question.initialAnswer !== undefined || customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + return { + customAnswer, + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): boolean { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); +} + +/** Toggles an offered option. An unknown option returns the same draft. */ +export function togglePendingUserInputOptionSelection( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): PendingUserInputDraftAnswer { + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } + + if (question.multiSelect) { + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; + + return { + customAnswer: "", + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionValues: [resolvedOptionValue], + }; +} + +/** Every question's answer keyed by question id, or null while any is unanswered. */ +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); + if (answer === null) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} From 1a1d4fb1fee9ce91ae46d5b3d873fa3b3da79a58 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 16:07:46 -0700 Subject: [PATCH 4/6] fix(tooling): context budget failures point at the evals before cuts The size budget fails when model-facing text grows, and its messages said to cut text. Cuts with no eval behind them caused the inline chart regression after #24850. Now that check:changed runs the budget locally, both messages say to run the evals that cover the text before cutting. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agent-context-budget.yml | 2 +- apps/server/src/provider/AgentContextBudget.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 9396d8e3e7a63..41efb7b69a5a2 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -51,5 +51,5 @@ jobs: echo "Accepted by the agent-context-increase label." exit 0 fi - echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text and rerun the evals that cover it (evals/README.md), or ask a maintainer to add the agent-context-increase label." exit 1 diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index ad19948c06886..9a26b53bff4e6 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -123,7 +123,10 @@ it.effect("agent context matches the committed budget", () => ), }; - expect(measured).toEqual(budget); + expect( + measured, + "Set agentContextBudget.json to the measured values. Before you cut text to fit, run the evals that cover it (evals/README.md).", + ).toEqual(budget); }).pipe( Effect.provide( McpHttpServer.layerTools.pipe( From d1449e23aad88c5d8bd54f5779f72010cf97f23f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 18:15:33 -0700 Subject: [PATCH 5/6] docs: check:changed lists its cross-file checks Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/operations/development.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd27babf3f81f..493f7fc0be715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. diff --git a/docs/operations/development.md b/docs/operations/development.md index 211041c22a24e..3307dbc22b969 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -163,7 +163,9 @@ for the security model. `vp run check:changed` runs CI's fast checks for your branch's changes: format and lint of the changed files, `knip:check` when code or a manifest changes, the generated GraphQL checks, typecheck -of the affected packages and their dependents, and the test files beside the changed files. It maps +of the affected packages and their dependents, and the test files beside the changed files. It also +runs the checks that read other files: the agent context budget when the server is affected, and +React Compiler coverage for only the changed web and mobile files. It maps files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), as CI does. It compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. From c0a135030eb9e71fa71316d5376ae88fa28e5f6c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 01:43:38 -0700 Subject: [PATCH 6/6] chore(tooling): pre-commit hook runs knip Co-Authored-By: Claude Opus 5.5 --- vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index ea4bbf1d2356c..468837c255686 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -151,6 +151,9 @@ export default defineConfig({ staged: { // In order, so lint reads the formatted file. CI lint failures cost a full round trip. "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], + // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave + // a file, export, or dependency unused. + "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check", }, fmt: { ignorePatterns: [