Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/agent-context-budget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <files>`. For big files, add `-t "<name>"`.
- 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.
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
104 changes: 0 additions & 104 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ import {
type ThreadFeedCache,
type ThreadFeedEntry,
type ThreadFeedLatestRun,
togglePendingUserInputOptionSelection,
setPendingUserInputCustomAnswer,
isPendingUserInputOptionSelected,
buildPendingUserInputAnswers,
} from "./threadActivity";

const threadId = ThreadId.make("thread-1");
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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 => ({
Expand Down
162 changes: 0 additions & 162 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -81,13 +80,6 @@ import {
export type PendingApproval = ThreadPendingApproval;
export type PendingUserInput = ThreadPendingUserInput;

export interface PendingUserInputDraftAnswer {
readonly selectedOptionValues?: ReadonlyArray<string>;
readonly customAnswer?: string;
readonly attachmentCount?: number;
readonly attachmentsBlocked?: boolean;
}

export interface ThreadFeedActivity {
readonly id: string;
readonly createdAt: string;
Expand Down Expand Up @@ -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<string> | undefined,
): ReadonlyArray<string> {
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<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(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)}`;
Expand Down Expand Up @@ -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<ThreadUserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): Record<string, string | ReadonlyArray<string>> | null {
const answers: Record<string, string | ReadonlyArray<string>> = {};

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<OrchestrationV2ProjectedTurnItem>;
readonly attempts: ReadonlyArray<OrchestrationV2RunAttempt> | undefined;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/state/use-selected-thread-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/AgentContextBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading