diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts
index 15e512aad101e..ced86f3bbe215 100644
--- a/apps/server/scripts/acp-mock-agent.ts
+++ b/apps/server/scripts/acp-mock-agent.ts
@@ -516,7 +516,7 @@ const program = Effect.gen(function* () {
parameterizedModelPicker = request.capabilities?._meta?.parameterizedModelPicker === true;
return {
protocolVersion: 2,
- info: { name: "t3-acp-mock-agent", version: "0.0.0" },
+ info: { name: "t3-acp-mock-agent", version: process.env.T3_ACP_AGENT_VERSION ?? "0.0.0" },
...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }),
capabilities: {
session: {
diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts
index 7f25587fd990d..f7357771c3276 100644
--- a/apps/server/src/checkpointing/CheckpointStore.test.ts
+++ b/apps/server/src/checkpointing/CheckpointStore.test.ts
@@ -431,10 +431,11 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
]);
}),
);
- it.effect("limits a diff to repository paths from a subdirectory workspace", () =>
+ it.effect("reads repository paths from a subdirectory workspace under diff.relative", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
+ yield* git(tmp, ["config", "diff.relative", "true"]);
const fileSystem = yield* FileSystem.FileSystem;
const cwd = NodePath.join(tmp, "app");
yield* fileSystem.makeDirectory(cwd);
@@ -447,6 +448,8 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
};
yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef });
yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n");
+ yield* git(cwd, ["add", "kept.txt"]);
+ yield* git(cwd, ["commit", "-m", "kept"]);
yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n");
yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef });
@@ -455,8 +458,10 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
ignoreWhitespace: false,
filePaths: ["app/kept.txt"],
});
+ expect(patch).toContain("+++ b/app/kept.txt");
expect(patch).toContain("+kept");
expect(patch).not.toContain("hidden");
+ expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["app/kept.txt"]));
}),
);
});
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index 530b1f2f1c767..281b11bc438fe 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -3081,6 +3081,56 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);
+ it.effect("create_pr reports a created PR when the lookup after creation fails", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(repoDir);
+ yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-after-create"]);
+ const remoteDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
+ NodeFS.writeFileSync(NodePath.join(repoDir, "lookup.txt"), "lookup\n");
+ yield* runGit(repoDir, ["add", "lookup.txt"]);
+ yield* runGit(repoDir, ["commit", "-m", "Lookup after create"]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-after-create"]);
+
+ const { service: github } = createFakeSourceControlProvider();
+ let created = false;
+ const { manager } = yield* makeManager({
+ sourceControlProvider: {
+ ...github,
+ createChangeRequest: (input) =>
+ github.createChangeRequest(input).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ created = true;
+ }),
+ ),
+ ),
+ listChangeRequests: (input) =>
+ created
+ ? Effect.fail(
+ new SourceControlProviderFailure({
+ provider: github.kind,
+ operation: "listChangeRequests",
+ cwd: input.cwd,
+ detail: "GitHub API rate limit exceeded.",
+ }),
+ )
+ : github.listChangeRequests(input),
+ },
+ });
+
+ const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
+
+ expect(created).toBe(true);
+ expect(result.pr).toMatchObject({
+ status: "created",
+ headBranch: "feature/lookup-after-create",
+ });
+ expect(result.pr.url).toBeUndefined();
+ }),
+ );
+
it.effect("returns existing PR metadata for commit/push/pr action", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index 1e4a9fb430bb3..9c973b7cb7475 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -2340,7 +2340,15 @@ export const make = Effect.gen(function* () {
})
.pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore)));
- const created = yield* findOpenPr(cwd, headContext);
+ // The PR already exists at this point, so a failed lookup only loses its metadata.
+ const created = yield* findOpenPr(cwd, headContext).pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("GitManager.runPrStep lookup after create failed", {
+ provider: provider.kind,
+ errorTag: error._tag,
+ }).pipe(Effect.as(null)),
+ ),
+ );
if (!created) {
return {
status: "created" as const,
diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts
index b956755423af5..50731b82cce8a 100644
--- a/apps/server/src/git/GitWorkflowService.ts
+++ b/apps/server/src/git/GitWorkflowService.ts
@@ -113,6 +113,7 @@ export class GitWorkflowService extends Context.Service<
readonly fetchWorktreeBaseCommit: (input: {
readonly cwd: string;
readonly refName: string;
+ readonly remote?: GitVcsDriver.GitWorktreeBaseRemote;
}) => Effect.Effect<
{ readonly commitSha: string; readonly remoteRefName: string } | null,
GitCommandError
diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index 8b513c4ca30eb..cb92c24242d47 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -444,6 +444,53 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(layerKeybindings())),
);
+ it.effect("keeps entries it cannot decode when editing other keybindings", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
+ const futureCommand = { key: "mod+shift+k", command: "command.from-a-newer-build" };
+ const badShortcut = { key: "mod+shift+d+o", command: "terminal.new" };
+ yield* fs.writeFileString(
+ keybindingsConfigPath,
+ JSON.stringify([
+ { key: "mod+r", command: "script.run-tests.run" },
+ futureCommand,
+ badShortcut,
+ ]),
+ );
+ const keybindings = yield* Keybindings.Keybindings;
+ const persisted = Effect.map(fs.readFileString(keybindingsConfigPath), JSON.parse);
+
+ const saved = yield* keybindings.upsertKeybindingRule({
+ key: "mod+shift+r",
+ command: "script.run-tests.run",
+ });
+ assert.equal(saved.issues.length, 2);
+ assert.deepEqual(yield* persisted, [
+ { key: "mod+r", command: "script.run-tests.run" },
+ futureCommand,
+ badShortcut,
+ { key: "mod+shift+r", command: "script.run-tests.run" },
+ ]);
+
+ const removed = yield* keybindings.removeKeybindingRule({
+ key: "mod+r",
+ command: "script.run-tests.run",
+ });
+ assert.deepEqual(yield* persisted, [
+ futureCommand,
+ badShortcut,
+ { key: "mod+shift+r", command: "script.run-tests.run" },
+ ]);
+ const { issues } = yield* keybindings.loadConfigState;
+ assert.deepEqual(removed.issues, issues);
+ assert.deepEqual(
+ issues.map((issue) => issue.kind === "keybindings.invalid-entry" && issue.index),
+ [0, 1],
+ );
+ }).pipe(Effect.provide(layerKeybindings())),
+ );
+
it.effect("removes a hand-written keybinding targeted by its settings spelling", () =>
Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts
index 1247428a56d82..0a5bfdd4246ca 100644
--- a/apps/server/src/keybindings.ts
+++ b/apps/server/src/keybindings.ts
@@ -1,6 +1,5 @@
import {
KeybindingRule,
- KeybindingsConfig,
KeybindingsConfigError,
KeybindingShortcut,
KeybindingWhenNode,
@@ -170,11 +169,11 @@ function encodeWhenAst(node: KeybindingWhenNode): string {
}
const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
-const KeybindingsConfigPrettyJson = fromJsonStringPretty(KeybindingsConfig);
+const RawKeybindingsEntriesPrettyJson = fromJsonStringPretty(Schema.Array(Schema.Unknown));
const decodeKeybindingRuleExit = Schema.decodeUnknownExit(KeybindingRule);
const decodeResolvedKeybindingFromConfigExit = Schema.decodeExit(ResolvedKeybindingFromConfig);
const decodeRawKeybindingsEntriesExit = Schema.decodeUnknownExit(RawKeybindingsEntries);
-const encodeKeybindingsConfigPrettyJson = Schema.encodeEffect(KeybindingsConfigPrettyJson);
+const encodeRawKeybindingsEntriesPrettyJson = Schema.encodeEffect(RawKeybindingsEntriesPrettyJson);
export interface KeybindingsConfigState {
readonly keybindings: ResolvedKeybindingsConfig;
@@ -234,11 +233,11 @@ export class Keybindings extends Context.Service<
*/
readonly upsertKeybindingRule: (
input: ServerUpsertKeybindingInput,
- ) => Effect.Effect;
+ ) => Effect.Effect;
readonly removeKeybindingRule: (
input: ServerRemoveKeybindingInput,
- ) => Effect.Effect;
+ ) => Effect.Effect;
}
>()("t3/keybindings") {}
@@ -278,8 +277,10 @@ const make = Effect.gen(function* () {
),
);
+ // Entries this build cannot decode, such as a typo or a command from a newer
+ // build, are written back unchanged so editing another shortcut keeps them.
const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
- readonly KeybindingRule[],
+ ReadonlyArray<{ readonly value: unknown; readonly rule: KeybindingRule | null }>,
KeybindingsConfigError
> {
if (!(yield* readConfigExists)) {
@@ -298,29 +299,16 @@ const make = Effect.gen(function* () {
),
);
- return yield* Effect.forEach(rawConfig, (entry) =>
- Effect.gen(function* () {
- const decodedRule = decodeKeybindingRuleExit(entry);
- if (decodedRule._tag === "Failure") {
- yield* Effect.logWarning("ignoring invalid keybinding entry", {
- path: keybindingsConfigPath,
- entry,
- error: Cause.pretty(decodedRule.cause),
- });
- return null;
- }
- const resolved = decodeResolvedKeybindingFromConfigExit(decodedRule.value);
- if (resolved._tag === "Failure") {
- yield* Effect.logWarning("ignoring invalid keybinding entry", {
- path: keybindingsConfigPath,
- entry,
- error: Cause.pretty(resolved.cause),
- });
- return null;
- }
- return decodedRule.value;
- }),
- ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));
+ return rawConfig.map((entry) => {
+ const decodedRule = decodeKeybindingRuleExit(entry);
+ if (
+ decodedRule._tag === "Success" &&
+ decodeResolvedKeybindingFromConfigExit(decodedRule.value)._tag === "Success"
+ ) {
+ return { value: decodedRule.value, rule: decodedRule.value };
+ }
+ return { value: entry, rule: null };
+ });
});
const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
@@ -403,8 +391,8 @@ const make = Effect.gen(function* () {
),
);
- const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
- return encodeKeybindingsConfigPrettyJson(rules).pipe(
+ const writeConfigAtomically = (entries: ReadonlyArray) => {
+ return encodeRawKeybindingsEntriesPrettyJson(entries).pipe(
Effect.map((encoded) => `${encoded}\n`),
Effect.flatMap((encoded) =>
writeFileStringAtomically({
@@ -444,13 +432,14 @@ const make = Effect.gen(function* () {
const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
- const revalidateAndEmit = upsertSemaphore.withPermits(1)(
- Effect.gen(function* () {
- yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
- const configState = yield* loadConfigStateFromCacheOrDisk;
- yield* emitChange(configState);
- }),
- );
+ const reloadAndEmit = Effect.gen(function* () {
+ yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
+ const configState = yield* loadConfigStateFromCacheOrDisk;
+ yield* emitChange(configState);
+ return configState;
+ });
+
+ const revalidateAndEmit = upsertSemaphore.withPermits(1)(reloadAndEmit);
const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
@@ -648,7 +637,8 @@ const make = Effect.gen(function* () {
const rule = keybindingRuleFromInput(input);
const replaceTarget = replaceTargetFromUpsertInput(input);
const nextConfig = [
- ...customConfig.filter((entry) => {
+ ...customConfig.filter(({ rule: entry }) => {
+ if (!entry) return true;
if (replaceTarget) {
return (
!isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule)
@@ -656,7 +646,7 @@ const make = Effect.gen(function* () {
}
return !isSameKeybindingRule(entry, rule);
}),
- rule,
+ { value: rule, rule },
];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
@@ -668,19 +658,8 @@ const make = Effect.gen(function* () {
maxEntries: MAX_KEYBINDINGS_COUNT,
});
}
- yield* writeConfigAtomically(cappedConfig);
- const nextResolved = mergeWithDefaultKeybindings(
- compileResolvedKeybindingsConfig(cappedConfig),
- );
- yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
- keybindings: nextResolved,
- issues: [],
- });
- yield* emitChange({
- keybindings: nextResolved,
- issues: [],
- });
- return nextResolved;
+ yield* writeConfigAtomically(cappedConfig.map(({ value }) => value));
+ return yield* reloadAndEmit;
}),
),
removeKeybindingRule: (input) =>
@@ -688,20 +667,11 @@ const make = Effect.gen(function* () {
Effect.gen(function* () {
const customConfig = yield* loadWritableCustomKeybindingsConfig();
const target = keybindingRuleFromInput(input);
- const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target));
- yield* writeConfigAtomically(nextConfig);
- const nextResolved = mergeWithDefaultKeybindings(
- compileResolvedKeybindingsConfig(nextConfig),
+ const nextConfig = customConfig.filter(
+ ({ rule }) => !rule || !isSameKeybindingRule(rule, target),
);
- yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
- keybindings: nextResolved,
- issues: [],
- });
- yield* emitChange({
- keybindings: nextResolved,
- issues: [],
- });
- return nextResolved;
+ yield* writeConfigAtomically(nextConfig.map(({ value }) => value));
+ return yield* reloadAndEmit;
}),
),
} satisfies Keybindings["Service"];
diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
index 3d76bfea85a3a..6b637ecc74dc2 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
@@ -9,6 +9,7 @@ import {
RunId,
ScheduledTaskId,
ThreadId,
+ TurnItemId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
@@ -205,6 +206,98 @@ it("readThread preserves caller access errors and prefers activity-run status ov
}).pipe(Effect.provide(layer), Effect.runPromise);
});
+it("readThread pages cut-off text without splitting an emoji across pages", async () => {
+ const text = "a😀bc";
+ const itemId = TurnItemId.make("turn-item-mcp-reasoning");
+ const projection = {
+ thread: baseThread({
+ threadId: parentThreadId,
+ title: "Parent",
+ instanceId: parentInstanceId,
+ model: "gpt-5.4",
+ }),
+ runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
+ visibleTurnItems: [],
+ runtimeRequests: [],
+ messages: [],
+ contextTransfers: [],
+ subagents: [],
+ updatedAt: now,
+ } as unknown as OrchestrationV2ThreadProjection;
+ const row = {
+ position: 0,
+ visibility: "visible",
+ sourceThreadId: parentThreadId,
+ sourceItemId: itemId,
+ item: {
+ type: "reasoning",
+ runId: activeRunId,
+ status: "completed",
+ title: null,
+ text,
+ updatedAt: now,
+ },
+ };
+
+ const layer = layerOrchestratorService.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getTimelinePage: () =>
+ Effect.succeed({
+ items: [row] as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"],
+ totalItems: 1,
+ hasMore: false,
+ }),
+ getThreadRecords: () => Effect.succeed(projection),
+ getProjectThreadRecords: () => Effect.succeed(projection),
+ getThreadShell: () =>
+ Effect.succeed(projection.thread as unknown as OrchestrationV2ThreadShell),
+ } satisfies Partial),
+ ServerSettings.layerTest(),
+ Layer.mock(ProviderRegistry.ProviderRegistry)({
+ getProviders: Effect.succeed([]),
+ } satisfies Partial),
+ Layer.mock(ProjectService.ProjectService)({}),
+ Layer.mock(SecretRequests.SecretRequests)({}),
+ Layer.mock(RemoteDelegation.RemoteDelegation)({}),
+ Layer.mock(ScheduledTaskService.ScheduledTaskService)({
+ list: () => Effect.succeed({ tasks: [] }),
+ } satisfies Partial),
+ Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
+ list: () => Effect.succeed([]),
+ } satisfies Partial),
+ NodeCrypto.layer,
+ ),
+ ),
+ );
+
+ await Effect.gen(function* () {
+ const service = yield* OrchestratorMcpService.OrchestratorMcpService;
+ for (const [maxCharsPerItem, expected] of [
+ [2, ["a", "😀", "bc"]],
+ [1, ["a", "😀", "b", "c"]],
+ ] as const) {
+ let textOffset = 0;
+ for (const [index, text] of expected.entries()) {
+ const result = yield* service.readThread(makeScope(), {
+ threadId: parentThreadId,
+ view: "activity",
+ itemId,
+ textOffset,
+ maxCharsPerItem,
+ });
+ textOffset += text.length;
+ expect(result.items[0]).toMatchObject({
+ text,
+ textTruncated: index < expected.length - 1,
+ nextTextOffset: index < expected.length - 1 ? textOffset : null,
+ });
+ }
+ }
+ }).pipe(Effect.provide(layer), Effect.runPromise);
+});
+
it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => {
const parentProjection = {
thread: baseThread({
diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts
index 987d1b9142bac..b4d239485899e 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.ts
@@ -627,7 +627,7 @@ function pageIncludesTerminalTaskResult(input: {
if (!matchesResult) return false;
const text = turnItemText(row.item);
- return text !== null && text.length <= input.maxChars;
+ return text !== null && text.length <= textPageEnd(text, 0, input.maxChars);
});
}
@@ -944,6 +944,15 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {
}
}
+/** A cut inside a surrogate pair would leave half an emoji on each page. */
+function textPageEnd(text: string | null, offset: number, maxChars: number): number {
+ const end = offset + maxChars;
+ if (text === null || end >= text.length) return end;
+ const last = text.charCodeAt(end - 1);
+ if (last < 0xd800 || last > 0xdbff) return end;
+ return end - 1 > offset ? end - 1 : end + 1;
+}
+
function timelineItem(input: {
readonly row: OrchestrationV2ThreadProjection["visibleTurnItems"][number];
readonly maxChars: number;
@@ -952,7 +961,7 @@ function timelineItem(input: {
}): OrchestratorMcpThreadTimelineItem {
const text = turnItemText(input.row.item);
const offset = input.textOffset ?? 0;
- const end = offset + input.maxChars;
+ const end = textPageEnd(text, offset, input.maxChars);
const textTruncated = text !== null && text.length > end;
const messageId =
input.row.item.type === "user_message" || input.row.item.type === "assistant_message"
@@ -1106,7 +1115,6 @@ const make = Effect.gen(function* () {
const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* (
scope: McpInvocationScope,
- includeUserMessages = true,
) {
yield* requireCapability(scope);
if (scope.thread === undefined) {
@@ -1115,7 +1123,8 @@ const make = Effect.gen(function* () {
limits: clientModeCeiling(scope.client),
} as const;
}
- const parent = yield* loadProjection(scope.thread.threadId, includeUserMessages);
+ // No loadCaller consumer reads the caller's own prompts.
+ const parent = yield* loadProjection(scope.thread.threadId, false);
return {
parent,
limits: {
@@ -1231,8 +1240,7 @@ const make = Effect.gen(function* () {
scope: McpInvocationScope,
threadId: ThreadId,
) {
- // Caller prompts are not used to read a timeline or acknowledge a child result.
- const { parent } = yield* loadCaller(scope, false);
+ const { parent } = yield* loadCaller(scope);
const shell = yield* threadManagement
.getThreadShell(threadId)
.pipe(Effect.mapError(threadManagementFailure));
diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
index a2ab0b095b1c7..8232de983bd90 100644
--- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
@@ -484,6 +484,20 @@ describe("pull request toolkit handlers", () => {
}),
);
+ it.effect("reports a dismissed stack member as not linked and leaves it dismissed", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ thread: makeThread([makeLink(9, { source: "stack-dismissed" })]),
+ });
+ const result = yield* harness.call("unlink_pull_request", {
+ repository: "t3tools/t3code",
+ number: 9,
+ });
+ expect(result.wasLinked).toBe(false);
+ expect(yield* Ref.get(harness.commands)).toEqual([]);
+ }),
+ );
+
it.effect("links several pull requests in one call", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
index 581dc6708dc22..7798ec11bff05 100644
--- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
+++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
@@ -446,7 +446,11 @@ const make = Effect.gen(function* () {
const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId);
const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
const target = yield* resolveTarget(input, project);
- if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
+ if (
+ !threadPullRequestsOf(thread).some(
+ (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
+ )
+ )
return {
host: target.host,
repository: target.repository,
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 1b942685680ee..b02530aa0b2dc 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -5299,6 +5299,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
outputTokens: queryExits ? 40 : 46,
cachedInputTokens: queryExits ? 2000 : 11455,
cacheCreationTokens: queryExits ? 500 : 2847,
+ ...(queryExits ? {} : { models: ["claude-sonnet-4-6"] }),
},
);
}
@@ -5950,14 +5951,19 @@ describe("ClaudeAdapterV2 background wake turns", () => {
const providerTurn = lastProviderTurn(harness.events);
assert.notEqual(providerTurn.nativeTurnRef?.nativeId, subagentUuid);
- const rollbackTo = (providerThreadTurns: ReadonlyArray) =>
+ const rollbackTo = (
+ providerThreadTurns: ReadonlyArray,
+ text = "Background task completed.",
+ targetTurn = providerTurn,
+ ) =>
harness.runtime.rollbackThread({
providerThread: harness.providerThread,
target: {
type: "provider_turn",
checkpointId: CheckpointId.make("checkpoint-claude-cursor-subagent-only"),
appRunOrdinal: 1,
- providerTurn,
+ providerTurn: targetTurn,
+ message: { role: "user", text, attachments: [] },
},
providerThreadTurns,
});
@@ -5974,6 +5980,64 @@ describe("ClaudeAdapterV2 background wake turns", () => {
]).pipe(Effect.flip);
assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError);
assert.include(String(error.cause), "no SDK assistant message cursor");
+ const earlierCursor = "00000000-0000-4000-8000-000000000648";
+ const turnsAcrossSilent = [
+ {
+ ...providerTurn,
+ id: ProviderTurnId.make("provider-turn-claude-cursor-earlier"),
+ ordinal: providerTurn.ordinal - 1,
+ nativeTurnRef: {
+ driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
+ nativeId: earlierCursor,
+ strength: "weak",
+ },
+ },
+ providerTurn,
+ {
+ ...providerTurn,
+ id: ProviderTurnId.make("provider-turn-claude-cursor-next"),
+ ordinal: providerTurn.ordinal + 1,
+ },
+ ] satisfies ReadonlyArray;
+ const semanticError = yield* rollbackTo(turnsAcrossSilent).pipe(Effect.flip);
+ assert.instanceOf(semanticError, ProviderAdapter.ProviderAdapterRollbackThreadError);
+ const pastSilentTurn = yield* rollbackTo(turnsAcrossSilent, "/compact");
+ assert.equal(
+ pastSilentTurn.providerThread.nativeConversationHeadRef?.nativeId,
+ earlierCursor,
+ );
+ const interruptedBeforeCompact = yield* rollbackTo(
+ [
+ {
+ ...providerTurn,
+ id: ProviderTurnId.make("provider-turn-claude-cursor-earlier"),
+ ordinal: 1,
+ nativeTurnRef: {
+ driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
+ nativeId: earlierCursor,
+ strength: "weak",
+ },
+ },
+ {
+ ...providerTurn,
+ id: ProviderTurnId.make("provider-turn-claude-interrupted-before-compact"),
+ ordinal: 2,
+ status: "interrupted",
+ },
+ { ...providerTurn, ordinal: 3 },
+ {
+ ...providerTurn,
+ id: ProviderTurnId.make("provider-turn-claude-cursor-next"),
+ ordinal: 4,
+ },
+ ],
+ "/compact",
+ { ...providerTurn, ordinal: 3 },
+ ).pipe(Effect.flip);
+ assert.instanceOf(
+ interruptedBeforeCompact,
+ ProviderAdapter.ProviderAdapterRollbackThreadError,
+ );
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
@@ -6369,6 +6433,59 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect.each([
+ { cooldown: " (reset after 1h2m3s)", seconds: 3723 },
+ { cooldown: " (reset after 4m 6s) (last error: rate limited)", seconds: 246 },
+ { cooldown: "", seconds: 0 },
+ ])("keeps a gateway's reset on a direct 429 with cooldown '$cooldown'", (input) =>
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ const now = yield* DateTime.now;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-direct-gateway-limit"),
+ text: "Continue.",
+ attachments: [],
+ }),
+ );
+ // An API-key session reports a gateway 429 only as text, with no rate_limit_info.
+ const result = `API Error: Request rejected (429) · All credentials for model claude-opus-5-5 are cooling down${input.cooldown}`;
+ yield* Queue.offerAll(harness.sdkMessages, [
+ makeAssistantErrorFrame({
+ uuid: "00000000-0000-4000-8000-000000000654",
+ error: "rate_limit",
+ text: result,
+ }),
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000000655",
+ result,
+ terminalReason: "api_error",
+ isError: true,
+ apiErrorStatus: 429,
+ }),
+ ]);
+ const terminal = yield* Queue.take(harness.terminalReceipts);
+ assert.equal(terminal.status, "failed");
+ if (terminal.status !== "failed") return;
+ assert.equal(terminal.failure.class, "usage_limit");
+ assert.equal(
+ terminal.failure.resetAt,
+ input.seconds > 0
+ ? DateTime.formatIso(
+ DateTime.makeUnsafe(DateTime.toEpochMillis(now) + input.seconds * 1_000),
+ )
+ : null,
+ );
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ );
+
it.effect("keeps the measured context when a usage limit ends the turn", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
@@ -9882,6 +9999,96 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect.each([
+ { card: "an approval", toolName: "Bash", toolInput: { command: "echo probe" } },
+ {
+ card: "a question",
+ toolName: "AskUserQuestion",
+ toolInput: {
+ questions: [
+ {
+ header: "Target",
+ question: "Where should this deploy?",
+ options: [
+ { label: "Production", description: "Deploy to production." },
+ { label: "Staging", description: "Deploy to staging." },
+ ],
+ multiSelect: false,
+ },
+ ],
+ },
+ },
+ ])("closes $card card when Claude withdraws its request", ({ toolName, toolInput }) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("attempt-claude-withdrawn-approval"),
+ text: "Run the probe.",
+ attachments: [],
+ runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
+ runtimeMode: "approval-required",
+ interactionMode: "default",
+ cwd: "/workspace",
+ }),
+ }),
+ );
+ // The CLI cancels a request once a PermissionRequest hook has answered it.
+ // @effect-diagnostics-next-line abortControllerInEffect:off - Claude, not the test fiber, withdraws the request.
+ const withdrawal = new AbortController();
+ const permission = yield* Effect.promise(() =>
+ harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, {
+ signal: withdrawal.signal,
+ toolUseID: "toolu_01WithdrawnApproval",
+ requestId: "request-withdrawn-approval",
+ }),
+ ).pipe(Effect.forkScoped);
+ const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest;
+ assert.equal(pending.status, "pending");
+
+ // The turn can end right after the withdrawal, before the callback returns.
+ withdrawal.abort();
+ yield* Queue.offer(
+ harness.sdkMessages,
+ claudeSdkFrame({
+ ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000790", result: "Done." }),
+ user_message_uuid: harness.promptUuid(0),
+ }),
+ );
+ yield* harness.awaitTerminal(1);
+ assert.equal((yield* Fiber.join(permission))?.behavior, "deny");
+ // The cancellation has to land before the turn's end finalizes the run.
+ const beforeTerminal = harness.events.slice(
+ 0,
+ harness.events.findIndex((event) => event.type === "turn.terminal"),
+ );
+ const statusOf = (type: "runtime_request.updated" | "node.updated" | "turn_item.updated") =>
+ beforeTerminal
+ .flatMap((event): Array<{ readonly type: string; readonly status: string }> =>
+ event.type === "runtime_request.updated" && event.runtimeRequest.id === pending.id
+ ? [{ type: event.type, status: event.runtimeRequest.status }]
+ : event.type === "node.updated" && event.node.id === pending.nodeId
+ ? [{ type: event.type, status: event.node.status }]
+ : event.type === "turn_item.updated" && event.turnItem.nodeId === pending.nodeId
+ ? [{ type: event.type, status: event.turnItem.status }]
+ : [],
+ )
+ .findLast((entry) => entry.type === type)?.status;
+ assert.equal(statusOf("runtime_request.updated"), "cancelled");
+ assert.equal(statusOf("node.updated"), "cancelled");
+ assert.equal(statusOf("turn_item.updated"), "cancelled");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
it.effect("releases output held for a prompt echo when the stream ends", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -12793,6 +13000,67 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect("keeps a foreground fork running past the placeholder result for its own launch", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("attempt-claude-foreground-fork"),
+ text: "Fork this.",
+ attachments: [],
+ }),
+ );
+ yield* harness.offerAndWait(
+ makeSubagentTaskStartedFrame({
+ taskId: "agent-fork",
+ toolUseId: "toolu-fork",
+ uuid: "00000000-0000-4000-8000-000000000901",
+ }),
+ );
+ // Claude Code opens a foreground fork's own stream with a placeholder result for its launch.
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ type: "user",
+ message: {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "toolu-fork",
+ content: "Fork started — that fork is you.",
+ },
+ ],
+ },
+ parent_tool_use_id: "toolu-fork",
+ uuid: "00000000-0000-4000-8000-000000000902",
+ session_id: WAKE_NATIVE_SESSION,
+ }),
+ );
+ yield* harness.offerAndWait(
+ makeSubagentNotificationFrame({
+ taskId: "agent-fork",
+ toolUseId: "toolu-fork",
+ summary: "FORK-DONE",
+ uuid: "00000000-0000-4000-8000-000000000903",
+ }),
+ );
+ let settled = yield* Queue.take(harness.subagentReceipts);
+ while (settled.subagent.status === "running") {
+ settled = yield* Queue.take(harness.subagentReceipts);
+ }
+ assert.equal(settled.subagent.result, "FORK-DONE");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
it.effect("releases the idle pin when a post-settle subagent stops without completing", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 325923b3611bf..7e5d20c7fd4ef 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -21,7 +21,14 @@ import {
} from "./claudeWorkflowProgress.ts";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
-import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
+import {
+ addClaudeLedgerUsage,
+ claudeLedgerDelta,
+ claudeModelLedger,
+ normalizeClaudeTurnTokenUsage,
+ type ClaudeLedgerUsage,
+ type ClaudeModelLedger,
+} from "../../provider/ClaudeTurnTokenUsage.ts";
import { toMcpElicitationResponse } from "../../provider/CodexMcpElicitation.ts";
// Coder: a type-only import, so the helper bundle never loads the SDK.
import type {
@@ -149,6 +156,7 @@ import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
import {
claudeSignedOutMessage,
makeClaudeEnvironment,
+ mergeClaudeProcessEnvironment,
resolveClaudeHomePath,
} from "../../provider/Drivers/ClaudeHome.ts";
import {
@@ -169,7 +177,6 @@ import {
type ClaudeScopedLimitNames,
} from "../../provider/claudeUsageLimits.ts";
import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
-import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions";
import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
import {
@@ -1847,6 +1854,16 @@ const getNativeThreadId = Effect.fnUntraced(function* (
const isSyntheticClaudeTurnId = (nativeTurnId: string): boolean => nativeTurnId.startsWith("turn:");
+const claudeTurnCursor = (turn: OrchestrationV2ProviderTurn): string | null => {
+ const ref = turn.nativeTurnRef;
+ return ref !== null &&
+ ref.driver === CLAUDE_PROVIDER &&
+ ref.nativeId !== null &&
+ !isSyntheticClaudeTurnId(ref.nativeId)
+ ? ref.nativeId
+ : null;
+};
+
const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>
turn.status === "completed" ||
turn.status === "interrupted" ||
@@ -1933,14 +1950,9 @@ const resolveClaudeRollbackResumeSessionAt = Effect.fn(
});
}
- const nativeTurnRef = target.providerTurn.nativeTurnRef;
- if (
- nativeTurnRef !== null &&
- nativeTurnRef.driver === CLAUDE_PROVIDER &&
- nativeTurnRef.nativeId !== null &&
- !isSyntheticClaudeTurnId(nativeTurnRef.nativeId)
- ) {
- return nativeTurnRef.nativeId;
+ const cursor = claudeTurnCursor(target.providerTurn);
+ if (cursor !== null) {
+ return cursor;
}
const providerTurnsAfterTarget = input.providerThreadTurns.filter(
@@ -1950,6 +1962,18 @@ const resolveClaudeRollbackResumeSessionAt = Effect.fn(
return null;
}
+ // Only compaction can be omitted without dropping a retained user prompt.
+ if (target.message !== undefined && isCompactionCommandMessage(target.message)) {
+ const earlierTurn = input.providerThreadTurns
+ .filter((turn) => turn.ordinal < target.providerTurn.ordinal)
+ .toSorted((left, right) => left.ordinal - right.ordinal)
+ .at(-1);
+ const earlierCursor = earlierTurn === undefined ? null : claudeTurnCursor(earlierTurn);
+ if (earlierCursor != null) {
+ return earlierCursor;
+ }
+ }
+
return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({
driver: CLAUDE_PROVIDER,
providerThreadId: input.providerThread.id,
@@ -3644,37 +3668,46 @@ function claudeUsageLimitResetAt(resetsAt: unknown): string | null {
: null;
}
+function gatewayCooldownResetAt(
+ cooldown: RegExpExecArray | null,
+ completedAt: DateTime.Utc,
+): string | undefined {
+ if (cooldown === null) return undefined;
+ const seconds =
+ Number(cooldown[1] ?? 0) * 3_600 + Number(cooldown[2] ?? 0) * 60 + Number(cooldown[3] ?? 0);
+ const resetMs = DateTime.toEpochMillis(completedAt) + seconds * 1_000;
+ return seconds > 0 && resetMs < 8.64e15
+ ? DateTime.formatIso(DateTime.makeUnsafe(resetMs))
+ : undefined;
+}
+
function providerFailureFromResult(
message: SDKResultMessage,
completedAt: DateTime.Utc,
failureHint?: string,
usageLimited = false,
): OrchestrationV2ProviderFailure | null {
- const gatewayLimit =
+ const gatewayResetAt =
failureHint === undefined &&
message.subtype === "success" &&
message.is_error &&
message.terminal_reason === "api_error" &&
message.api_error_status === 503
- ? /^API Error: 503 \[[^\]\r\n]+\] \[429\]: \{"type":"error","error":\{"type":"rate_limit_error",[^\r\n]*\} \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(
- message.result,
+ ? gatewayCooldownResetAt(
+ /^API Error: 503 \[[^\]\r\n]+\] \[429\]: \{"type":"error","error":\{"type":"rate_limit_error",[^\r\n]*\} \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(
+ message.result,
+ ),
+ completedAt,
)
- : null;
- if (gatewayLimit) {
- const seconds =
- Number(gatewayLimit[1] ?? 0) * 3_600 +
- Number(gatewayLimit[2] ?? 0) * 60 +
- Number(gatewayLimit[3] ?? 0);
- const resetMs = DateTime.toEpochMillis(completedAt) + seconds * 1_000;
- if (seconds > 0 && resetMs < 8.64e15) {
- return makeProviderFailure({
- class: "usage_limit",
- message: "Claude API rate limit reached. Try again later.",
- code: "api_error_429",
- retryable: true,
- resetAt: DateTime.formatIso(DateTime.makeUnsafe(resetMs)),
- });
- }
+ : undefined;
+ if (gatewayResetAt !== undefined) {
+ return makeProviderFailure({
+ class: "usage_limit",
+ message: "Claude API rate limit reached. Try again later.",
+ code: "api_error_429",
+ retryable: true,
+ resetAt: gatewayResetAt,
+ });
}
const failureClass =
(message.subtype === "success" && message.api_error_status === 429) || usageLimited
@@ -3703,6 +3736,14 @@ function providerFailureFromResult(
return null;
}
const apiErrorStatus = message.api_error_status ?? null;
+ // API-key sessions get no rate_limit_info, so a gateway's 429 carries its reset only as text.
+ const resetAt =
+ apiErrorStatus === 429 && message.result.startsWith("API Error:")
+ ? gatewayCooldownResetAt(
+ /\(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(message.result),
+ completedAt,
+ )
+ : undefined;
return makeProviderFailure({
message: listedError ?? structuredError ?? failureHint ?? message.result,
code:
@@ -3711,6 +3752,7 @@ function providerFailureFromResult(
: `api_error_${apiErrorStatus}`,
class: failureClass,
retryable: apiErrorStatus === 429 || apiErrorStatus === 529 ? true : null,
+ resetAt: resetAt ?? null,
});
}
@@ -3920,6 +3962,9 @@ interface ActiveClaudeTurnContext {
compactionFailure: { readonly message: string | undefined } | null;
// Steers end segments whose usage is excluded from the final result; bill them too, oldest first.
readonly steeredSegmentUsage: Array;
+ // Ledger growth of the steered segments; undefined once any segment's growth is unknown.
+ steeredLedgerUsage: ClaudeLedgerUsage | undefined;
+ readonly rootModels: Set;
readonly subagentsByTaskId: Map;
readonly subagentsByToolUseId: Map;
readonly promptUuid: string | null;
@@ -3971,6 +4016,8 @@ interface ClaudeLiveQueryContext {
permissionMode: PermissionMode;
stopping: boolean;
readonly subagentsFromEarlierProcesses: ReadonlySet;
+ // The SDK's per-process model ledger at the last result; a new process starts at zero.
+ modelLedger: ClaudeModelLedger | undefined;
// Per process, not per session: a relative temp dir follows the cwd this CLI
// was launched in, and a later turn's cwd can differ.
readonly taskOutputRoot: string | undefined;
@@ -4628,6 +4675,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const interruptedTurns = yield* Ref.make(new Set());
const steeredTurns = yield* Ref.make(new Set());
const queryContext = yield* Ref.make(null);
+ // Wake buffers replay result frames, so ledger growth is measured once on arrival.
+ const resultLedgerUsage = new WeakMap();
// A worktree handoff changes the project settings even within one session.
let bypassCache: { cwd: string | null; availability: ClaudeBypassAvailability } | null =
null;
@@ -7128,6 +7177,40 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
return { node, request, turnItem };
});
+ // Claude withdraws a permission request it no longer needs, for example
+ // when a PermissionRequest hook answered it. Close the request and its
+ // card, which would otherwise wait for an answer nothing reads.
+ const cancelWithdrawnRequest = Effect.fnUntraced(function* (
+ artifacts: Effect.Success>,
+ ) {
+ const now = yield* DateTime.now;
+ yield* Effect.all(
+ [
+ emitProviderEvent({
+ type: "runtime_request.updated",
+ driver: CLAUDE_PROVIDER,
+ runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now },
+ }),
+ emitProviderEvent({
+ type: "node.updated",
+ driver: CLAUDE_PROVIDER,
+ node: { ...artifacts.node, status: "cancelled", completedAt: now },
+ }),
+ emitProviderEvent({
+ type: "turn_item.updated",
+ driver: CLAUDE_PROVIDER,
+ turnItem: {
+ ...artifacts.turnItem,
+ status: "cancelled",
+ completedAt: now,
+ updatedAt: now,
+ },
+ }),
+ ],
+ { concurrency: 1 },
+ );
+ });
+
const ensureReasoningBlock = Effect.fnUntraced(function* (
context: ActiveClaudeTurnContext,
itemId: string,
@@ -7432,6 +7515,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
input.context.subagentsByToolUseId.size > 0,
input.status,
input.context.steeredSegmentUsage,
+ {
+ rootModels: input.context.rootModels,
+ ...(input.result === undefined
+ ? {}
+ : {
+ ledger: addClaudeLedgerUsage(
+ input.context.steeredLedgerUsage,
+ resultLedgerUsage.get(input.result),
+ ),
+ }),
+ },
),
},
}),
@@ -8347,6 +8441,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (message.type === "assistant" && input.replayed !== true) {
if (message.parent_tool_use_id === null) {
+ // CLI notices and API errors arrive as assistant messages from "".
+ if (message.message.model && message.message.model !== "") {
+ context.rootModels.add(message.message.model);
+ }
// Subagent uuids exist only in the sidechain transcript, so resume and fork cannot target them.
context.nativeMessageCursor = message.uuid;
context.latestAssistantRateLimit =
@@ -8982,6 +9080,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) {
+ // A foreground fork's stream opens with a placeholder result for its own launch call.
+ if (parentToolUseIdFromSdkMessage(message) === toolResult.tool_use_id) continue;
const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id);
const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output));
if (subagent !== undefined && runHandles !== undefined) {
@@ -9226,6 +9326,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);
if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) {
context.steeredSegmentUsage.push(message.usage);
+ context.steeredLedgerUsage = addClaudeLedgerUsage(
+ context.steeredLedgerUsage,
+ resultLedgerUsage.get(message),
+ );
return;
}
yield* Ref.update(steeredTurns, (current) => {
@@ -9603,6 +9707,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}),
),
);
+ if (!(yield* Deferred.isDone(answers))) yield* cancelWithdrawnRequest(artifacts);
return callbackOptions.signal.aborted
? ({
behavior: "deny",
@@ -9743,6 +9848,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}),
),
);
+ if (!(yield* Deferred.isDone(decision))) yield* cancelWithdrawnRequest(artifacts);
return permissionResultFromDecision({
toolName,
@@ -10218,6 +10324,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(subagent) => subagent.task.status === "running",
),
),
+ modelLedger: new Map(),
taskOutputRoot: claudeTaskOutputRoot(
adapterOptions.environment,
path,
@@ -10246,6 +10353,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
) {
context.permissionMode = message.permissionMode;
}
+ if (message.type === "result") {
+ const ledger = claudeModelLedger(message.modelUsage);
+ const growth = claudeLedgerDelta(context.modelLedger, ledger);
+ if (growth !== undefined) resultLedgerUsage.set(message, growth);
+ // A ledger that went backwards cannot anchor the next turn's growth.
+ context.modelLedger =
+ growth === undefined && context.modelLedger !== undefined
+ ? undefined
+ : ledger;
+ }
return handleSdkMessage({ query: querySession, message });
}),
),
@@ -10391,6 +10508,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
latestAssistantRateLimit: null,
compactionFailure: null,
steeredSegmentUsage: [],
+ steeredLedgerUsage: { inputTokens: 0, outputTokens: 0 },
+ rootModels: new Set(),
subagentsByTaskId: new Map(),
subagentsByToolUseId: new Map(),
promptUuid,
@@ -11423,7 +11542,11 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
const queryRunner = yield* ClaudeAgentSdkQueryRunner;
const serverConfig = yield* ServerConfig.ServerConfig;
const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
- const baseEnvironment = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment);
+ const baseEnvironment = yield* mergeClaudeProcessEnvironment(
+ config,
+ environment,
+ hostEnvironment,
+ );
const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
index dd64c31c24174..5cc3372a4c867 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
@@ -271,6 +271,7 @@ describe("CodexAdapterV2 process spawning", () => {
assert.deepEqual(
CodexAdapterV2.codexThreadRuntimeParams({
+ threadId,
mcpSession,
modelSelection: { model: "gpt-5.4" },
runtimePolicy: {
@@ -286,6 +287,7 @@ describe("CodexAdapterV2 process spawning", () => {
"tools.update_plan.enabled": true,
// Codex waits up to 10 s, not 1 s, for MCP servers before the first request (#13437).
mcp_optional_startup_grace_ms: 10_000,
+ "shell_environment_policy.set.T3CODE_THREAD_ID": "thread-codex-mcp",
},
},
);
@@ -524,6 +526,11 @@ const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy
interactionMode: "default",
cwd: "/workspace",
});
+const T3_THREAD_ENV_KEY = "shell_environment_policy.set.T3CODE_THREAD_ID";
+const threadEnvOf = (params: unknown) =>
+ Predicate.hasProperty(params, "config") && Predicate.hasProperty(params.config, T3_THREAD_ENV_KEY)
+ ? params.config[T3_THREAD_ENV_KEY]
+ : undefined;
function makeCodexTestAppThread(input: {
readonly threadId: ThreadId;
@@ -1218,6 +1225,13 @@ describe("CodexAdapterV2 post-settle continuation", () => {
onRequest(method, params).pipe(
Effect.andThen(client.request(method, params)),
),
+ raw: {
+ ...client.raw,
+ request: (method, params) =>
+ onRequest(method, params).pipe(
+ Effect.andThen(client.raw.request(method, params)),
+ ),
+ },
}) satisfies CodexClient.CodexAppServerClient["Service"],
),
Effect.provide(context),
@@ -1553,6 +1567,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
config: {
"tools.update_plan.enabled": true,
mcp_optional_startup_grace_ms: 10_000,
+ "shell_environment_policy.set.T3CODE_THREAD_ID": `thread-${scenario}`,
},
},
]);
@@ -2412,6 +2427,116 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);
+ it.effect.each([
+ { hook: "stopped", status: "interrupted", expected: true },
+ { hook: "completed", status: "interrupted", expected: false },
+ { hook: "warning", status: "interrupted", expected: false },
+ { hook: "empty", status: "interrupted", expected: false },
+ { hook: "wrong-turn", status: "interrupted", expected: false },
+ { hook: "wrong-thread", status: "interrupted", expected: false },
+ { hook: "unscoped", status: "interrupted", expected: false },
+ { hook: "stopped", status: "completed", expected: false },
+ { hook: "stopped", status: "failed", expected: false },
+ ] as const)(
+ "correlates $hook hook output with a $status turn only",
+ ({ hook, status, expected }) =>
+ Effect.gen(function* () {
+ const nativeThreadId = "hook-thread";
+ const stopReason = "Save a current task checkpoint before compaction.";
+ const hookCompleted = (turnId: string | null) => ({
+ type: "emit_inbound" as const,
+ label: "hook/completed",
+ frame: {
+ method: "hook/completed",
+ params: {
+ threadId: hook === "wrong-thread" ? "other-thread" : nativeThreadId,
+ turnId: hook === "unscoped" ? null : hook === "wrong-turn" ? "other-turn" : turnId,
+ run: {
+ id: "checkpoint-hook",
+ eventName: "preCompact",
+ source: "project",
+ sourcePath: "/workspace/.codex/hooks.json",
+ scope: "turn",
+ executionMode: "sync",
+ handlerType: "command",
+ displayOrder: 1,
+ startedAt: 1782622440,
+ status: hook === "completed" ? "completed" : "stopped",
+ entries: [
+ { kind: "warning", text: "Unrelated hook diagnostic" },
+ {
+ kind: hook === "warning" ? "warning" : "stop",
+ text: hook === "empty" ? " " : stopReason,
+ },
+ ],
+ },
+ },
+ },
+ });
+ const transcript = makeCodexReplayTranscript({
+ scenario: `hook-stop-${hook}-${status}`,
+ entries: [
+ ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "hook-turn", prompt: "work" }),
+ hookCompleted("hook-turn"),
+ {
+ type: "emit_inbound",
+ label: "turn/completed",
+ frame: {
+ method: "turn/completed",
+ params: {
+ threadId: nativeThreadId,
+ turn: makeCodexReplayTurn({ id: "hook-turn", status }),
+ },
+ },
+ },
+ ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "next-turn", prompt: "work" })
+ .slice(-3)
+ .map((entry, index) => (index < 2 ? withReplayRequestId(entry, 4) : entry)),
+ hookCompleted("hook-turn"),
+ {
+ type: "emit_inbound",
+ label: "turn/completed/next",
+ frame: {
+ method: "turn/completed",
+ params: {
+ threadId: nativeThreadId,
+ turn: makeCodexReplayTurn({ id: "next-turn", status: "interrupted" }),
+ },
+ },
+ },
+ ],
+ });
+ const harness = yield* makeCodexReplayHarness(transcript);
+ for (const turn of ["hook-turn", "next-turn"]) {
+ yield* harness.runtime.startTurn(
+ makeCodexTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make(turn),
+ text: "work",
+ }),
+ );
+ yield* harness.awaitTerminal(turn === "hook-turn" ? 1 : 2);
+ }
+ assert.deepEqual(
+ harness.terminalEvents().map((event) => ({
+ status: event.status,
+ reason: event.status === "failed" ? undefined : event.reason,
+ })),
+ [
+ { status, reason: expected ? `preCompact hook (project): ${stopReason}` : undefined },
+ { status: "interrupted", reason: undefined },
+ ],
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ );
+
it.effect("resumes a provider thread without requesting or decoding its history", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -2444,18 +2569,75 @@ describe("CodexAdapterV2 post-settle continuation", () => {
label: "thread/resume",
frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } },
},
+ {
+ type: "expect_outbound",
+ label: "thread/resume without an app thread",
+ frame: {
+ id: 4,
+ method: "thread/resume",
+ params: {
+ threadId: nativeThreadId,
+ excludeTurns: true,
+ config: CodexAdapterV2.CODEX_THREAD_CONFIG,
+ },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "thread/resume without an app thread",
+ frame: { id: 4, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } },
+ },
+ {
+ type: "expect_outbound",
+ label: "thread/resume with an explicit app thread",
+ frame: {
+ id: 5,
+ method: "thread/resume",
+ params: {
+ threadId: nativeThreadId,
+ excludeTurns: true,
+ config: CodexAdapterV2.CODEX_THREAD_CONFIG,
+ },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "thread/resume with an explicit app thread",
+ frame: { id: 5, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } },
+ },
],
});
- const harness = yield* makeCodexReplayHarness(transcript);
+ const resumeThreadEnvs: Array = [];
+ const harness = yield* makeCodexReplayHarness(
+ transcript,
+ () => Effect.void,
+ (method, params) =>
+ Effect.sync(() => {
+ if (method === "thread/resume") resumeThreadEnvs.push(threadEnvOf(params));
+ }),
+ );
const resumed = yield* harness.runtime.resumeThread({
providerThread: harness.providerThread,
modelSelection: CODEX_TEST_MODEL_SELECTION,
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
});
+ yield* harness.runtime.resumeThread({
+ providerThread: { ...harness.providerThread, appThreadId: null },
+ modelSelection: CODEX_TEST_MODEL_SELECTION,
+ runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
+ });
+ const explicitThreadId = ThreadId.make(`thread-${scenario}-explicit`);
+ yield* harness.runtime.resumeThread({
+ threadId: explicitThreadId,
+ providerThread: harness.providerThread,
+ modelSelection: CODEX_TEST_MODEL_SELECTION,
+ runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
+ });
assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId);
assert.equal(resumed.status, "idle");
assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);
+ assert.deepEqual(resumeThreadEnvs, [harness.threadId, undefined, explicitThreadId]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
@@ -7410,12 +7592,14 @@ describe("CodexAdapterV2 post-settle continuation", () => {
],
});
const outbound: Array = [];
+ const forkThreadEnvs: Array = [];
const harness = yield* makeCodexReplayHarness(
transcript,
() => Effect.void,
- (method) =>
+ (method, params) =>
Effect.sync(() => {
outbound.push(method);
+ if (method === "thread/fork") forkThreadEnvs.push(threadEnvOf(params));
}),
);
const now = yield* DateTime.now;
@@ -7445,6 +7629,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
assert.notEqual(forkedProviderThread.id, harness.providerThread.id);
assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id);
assert.deepEqual(outbound.slice(-2), ["thread/turns/list", "thread/revert"]);
+ assert.deepEqual(forkThreadEnvs, ["thread-fork-paginated-fallback-target"]);
}).pipe(
Effect.scoped,
Effect.provide(
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index a4c6c61365f2e..9497f536ab019 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -100,6 +100,7 @@ import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts";
+import * as PortScanner from "../../preview/PortScanner.ts";
import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
import { ServerConfig } from "../../config.ts";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
@@ -1108,6 +1109,7 @@ function codexUsageLimitError(
}
interface ActiveCodexTurnContext {
+ hookStopReason?: string;
capacityRetryAttempt?: number;
capacityRetryPending?: Deferred.Deferred;
closedCommandItemIds?: ReadonlySet;
@@ -1319,6 +1321,7 @@ export const CODEX_THREAD_CONFIG = {
} as const;
export function codexThreadRuntimeParams(input: {
+ readonly threadId: ThreadId | null;
readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
readonly modelSelection?: { readonly model: string };
readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
@@ -1331,7 +1334,13 @@ export function codexThreadRuntimeParams(input: {
return {
...(input.runtimePolicy?.cwd == null ? {} : { cwd: input.runtimePolicy.cwd }),
...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }),
- config: { ...CODEX_THREAD_CONFIG },
+ config: {
+ ...CODEX_THREAD_CONFIG,
+ // One app-server serves many threads, so the id goes in per-thread shell config, not its env.
+ ...(input.threadId === null
+ ? {}
+ : { [`shell_environment_policy.set.${PortScanner.AGENT_THREAD_ENV}`]: input.threadId }),
+ },
};
}
@@ -1794,6 +1803,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
const turnWaiters = yield* Ref.make(new Map>());
const subagentThreads = yield* Ref.make(new Map());
const subagentSelections = new Map>();
+ // A reroute names one turn; until new settings arrive, the thread's model is unknown.
+ const reroutedTurnModels = new Map();
+ const reroutedThreads = new Set();
+ const reportedTurnModel = (nativeThreadId: string, nativeTurnId: string) => {
+ const rerouted = reroutedTurnModels.get(nativeTurnId);
+ reroutedTurnModels.delete(nativeTurnId);
+ if (rerouted !== undefined) return rerouted;
+ return reroutedThreads.has(nativeThreadId)
+ ? undefined
+ : subagentSelections.get(nativeThreadId)?.model;
+ };
const pendingSubagentTurns = yield* Ref.make(
new Map>(),
);
@@ -4439,15 +4459,38 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
);
yield* client.handleServerNotification("thread/settings/updated", (payload) =>
- updateSubagentSelection(
- payload.threadId,
- payload.threadSettings.model,
- payload.threadSettings.effort,
- payload.threadSettings.serviceTier,
+ Effect.sync(() => reroutedThreads.delete(payload.threadId)).pipe(
+ Effect.andThen(
+ updateSubagentSelection(
+ payload.threadId,
+ payload.threadSettings.model,
+ payload.threadSettings.effort,
+ payload.threadSettings.serviceTier,
+ ),
+ ),
),
);
yield* client.handleServerNotification("model/rerouted", (payload) =>
- updateSubagentSelection(payload.threadId, payload.toModel),
+ Effect.sync(() => {
+ reroutedTurnModels.set(payload.turnId, payload.toModel);
+ reroutedThreads.add(payload.threadId);
+ }).pipe(Effect.andThen(updateSubagentSelection(payload.threadId, payload.toModel))),
+ );
+
+ yield* client.handleServerNotification("hook/completed", (payload) =>
+ Effect.gen(function* () {
+ if (payload.turnId == null || payload.run.status !== "stopped") return;
+ const context = (yield* Ref.get(activeTurns)).get(payload.turnId);
+ if (context?.providerThread.nativeThreadRef?.nativeId !== payload.threadId) return;
+ const reason = payload.run.entries
+ .filter((entry) => entry.kind === "stop")
+ .map((entry) => entry.text.trim())
+ .filter(Boolean)
+ .join("\n");
+ if (!reason) return;
+ const source = payload.run.source === undefined ? "" : ` (${payload.run.source})`;
+ context.hookStopReason = `${payload.run.eventName} hook${source}: ${reason}`;
+ }),
);
yield* client.handleServerNotification("turn/started", (payload) =>
@@ -5709,6 +5752,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
runOrdinal: input.context.input.runOrdinal,
status: terminalStatus,
failure: null,
+ ...(terminalStatus === "interrupted" && input.context.hookStopReason !== undefined
+ ? { reason: input.context.hookStopReason }
+ : {}),
threadDisposition: "reusable",
};
},
@@ -6064,6 +6110,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
),
input.nativeTurnId,
input.status === "completed",
+ reportedTurnModel(
+ input.context.providerThread.nativeThreadRef?.nativeId ?? "",
+ input.nativeTurnId,
+ ),
),
};
if (!holdsForGoal) {
@@ -6925,6 +6975,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
client.request(
"thread/start",
codexThreadRuntimeParams({
+ threadId: threadInput.threadId,
mcpSession,
modelSelection: threadInput.modelSelection,
runtimePolicy: threadInput.runtimePolicy,
@@ -6953,13 +7004,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
resumeThread: Effect.fn("CodexAdapterV2.resumeThread")(
function* (threadInput) {
const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread);
+ const appThreadId = threadInput.threadId ?? threadInput.providerThread.appThreadId;
const resume = client.raw.request("thread/resume", {
threadId: nativeThreadId,
excludeTurns: true,
...codexThreadRuntimeParams({
- mcpSession: yield* readMcpSession(
- threadInput.threadId ?? threadInput.providerThread.appThreadId,
- ),
+ threadId: appThreadId,
+ mcpSession: yield* readMcpSession(appThreadId),
...(threadInput.modelSelection === undefined
? {}
: { modelSelection: threadInput.modelSelection }),
@@ -7744,6 +7795,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
threadId,
excludeTurns: true,
...codexThreadRuntimeParams({
+ threadId: threadInput.providerThread.appThreadId,
mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId),
modelSelection: input.modelSelection,
runtimePolicy: input.runtimePolicy,
@@ -7796,6 +7848,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
? {}
: { lastTurnId: boundary.lastTurnId }),
...codexThreadRuntimeParams({
+ threadId: threadInput.targetThreadId,
mcpSession: yield* mcpSessions.read(threadInput.targetThreadId),
...(threadInput.modelSelection === undefined
? {}
diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
index 385de626cfdf8..120efef2eaeec 100644
--- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
+++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
@@ -183,7 +183,11 @@ export const layer: Layer.Layer<
const targetTurn =
latestProviderTurnForAttempt(context.providerTurns, targetAttempt?.id) ??
context.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
- if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
+ if (
+ targetAttempt === undefined ||
+ targetTurn === undefined ||
+ targetTurn.providerThreadId !== providerThread.id
+ ) {
return yield* new CheckpointRollbackExecutionError({
reason: "provider-turn-unavailable",
threadId: input.threadId,
@@ -191,11 +195,21 @@ export const layer: Layer.Layer<
checkpointId: input.checkpointId,
});
}
+ const { attempt } = yield* projections.getProviderControlContext(input.threadId, {
+ providerThreadId: providerThread.id,
+ providerTurnId: targetTurn.id,
+ attemptId: targetAttempt.id,
+ });
+ const message =
+ attempt === undefined
+ ? undefined
+ : yield* projections.getRunMessage(input.threadId, attempt.runId);
return {
type: "provider_turn" as const,
checkpointId: checkpoint.id,
appRunOrdinal: targetOrdinal,
providerTurn: targetTurn,
+ ...(message === undefined ? {} : { message }),
};
});
diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
index 5053e8d568767..87f6bbd1a6745 100644
--- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
+++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
@@ -3193,9 +3193,12 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => {
occurredAt: now,
payload: {
...providerThread,
- pendingBackgroundTasks: open
- ? [{ taskId: "sleep-900", kind: "command" as const }]
- : [],
+ pendingBackgroundTasks: [
+ ...(open
+ ? [{ taskId: "sleep-900", kind: "command" as const, awaited: true as const }]
+ : []),
+ { taskId: "log-watcher", kind: "monitor" as const },
+ ],
},
}
: {
diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
index 770da19398485..9d261469c20db 100644
--- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
+++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
@@ -841,7 +841,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
const messageId = MessageId.make("message:foundation-compact");
const threadStateEvent = (
suffix: string,
- type: "thread.visited" | "thread.metadata-updated",
+ type: "thread.visited" | "thread.metadata-updated" | "thread.tags-updated",
): OrchestrationV2DomainEvent => ({
id: EventId.make(`event:foundation-compact:${suffix}`),
type,
@@ -936,10 +936,13 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
...Array.from({ length: 501 }, (_, index) =>
threadStateEvent(`history-${index}`, "thread.visited"),
),
+ threadStateEvent("tags-1", "thread.tags-updated"),
threadStateEvent("visit-2", "thread.visited"),
messageEvent("message-2", "final"),
nodeEvent("node-2", "completed"),
itemEvent("item-2", "final"),
+ // Older clients skip tag events, so these must not remove the rename above.
+ threadStateEvent("tags-2", "thread.tags-updated"),
],
});
const beforeCompaction = yield* projections.getThreadProjection(threadId);
@@ -1002,6 +1005,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
"event:foundation-compact:message-2",
"event:foundation-compact:node-2",
"event:foundation-compact:item-2",
+ "event:foundation-compact:tags-2",
],
);
diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts
index 65360492bd2df..3c13452e98e7f 100644
--- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts
+++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts
@@ -308,4 +308,89 @@ describe("IdleCompactionService", () => {
);
},
);
+
+ it.effect("keeps the latest run's compaction when an earlier run reports completed again", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(NOW_MS);
+ const base = records({ cacheExpiresInMs: 10 * MINUTE_MS });
+ const laterRunId = RunId.make("run-2");
+ const laterMessageId = MessageId.make("message-2");
+ const earlierRun = base.runs[0]!;
+ const laterRun = {
+ ...earlierRun,
+ id: laterRunId,
+ ordinal: 2,
+ userMessageId: laterMessageId,
+ };
+ const data: OrchestrationV2ThreadProjection = {
+ ...base,
+ runs: [earlierRun, laterRun],
+ messages: [
+ ...base.messages,
+ { ...base.messages[0]!, id: laterMessageId, runId: laterRunId, text: "Keep going" },
+ ],
+ providerThreads: [{ ...base.providerThreads[0]!, lastRunOrdinal: 2 }],
+ attempts: [],
+ nodes: [],
+ subagents: [],
+ providerSessions: [],
+ plans: [],
+ checkpointScopes: [],
+ checkpoints: [],
+ contextHandoffs: [],
+ visibleTurnItems: [],
+ updatedAt: at(),
+ };
+ const completed = (run: typeof earlierRun) => ({
+ id: EventId.make(`completed-${run.id}`),
+ type: "run.updated" as const,
+ threadId,
+ runId: run.id,
+ occurredAt: at(),
+ payload: run,
+ });
+ const dispatched: Array = [];
+ const streamDone = yield* Deferred.make();
+ const dependencies = Layer.mergeAll(
+ Layer.mock(Orchestrator.OrchestratorV2)({
+ // A delegated completion on the earlier run lands after the later run completed.
+ streamDomainEvents: Stream.make(completed(laterRun), completed(earlierRun)).pipe(
+ Stream.ensuring(Deferred.succeed(streamDone, undefined)),
+ ),
+ dispatch: (command) =>
+ Effect.sync(() => {
+ dispatched.push(command.commandId);
+ return { sequence: 1, storedEvents: [] };
+ }),
+ }),
+ Layer.mock(ProjectionStore.ProjectionStoreV2)({
+ getThread: () => Effect.succeed(data.thread),
+ getThreadRecords: (_threadId, _keys, options) =>
+ Effect.succeed({
+ ...data,
+ messages: data.messages.filter(
+ (message) =>
+ options?.messageRunIds === undefined ||
+ (message.runId !== null && options.messageRunIds.includes(message.runId)),
+ ),
+ }),
+ }),
+ Layer.mock(ServerSettings.ServerSettingsService)({
+ getSettings: Effect.succeed({
+ ...DEFAULT_SERVER_SETTINGS,
+ idleCompaction: "beforeCacheExpires",
+ }),
+ }),
+ );
+ yield* Effect.gen(function* () {
+ const service = yield* IdleCompactionService.make;
+ yield* service.start();
+ yield* Deferred.await(streamDone);
+ yield* TestClock.adjust(9 * MINUTE_MS);
+ }).pipe(Effect.provide(dependencies));
+ expect(dispatched).toEqual(["server:idle-compact:idle-thread:run-2"]);
+ }),
+ ),
+ );
});
diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts
index 415bb14fa1a88..ce47b0f697700 100644
--- a/apps/server/src/orchestration-v2/IdleCompactionService.ts
+++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts
@@ -31,8 +31,9 @@ export const make = Effect.gen(function* () {
const settings = yield* ServerSettings.ServerSettingsService;
const compactWhenDue = Effect.fn("IdleCompactionService.compactWhenDue")(
- function* (threadId: ThreadId, runId: RunId) {
- // Loops once after the wait: the thread may have moved on.
+ function* (threadId: ThreadId, completedRunId: RunId) {
+ let runId = completedRunId;
+ // Loops after the wait or a retarget: the thread may have moved on.
while (true) {
const current = yield* settings.getSettings;
const thread = yield* projections.getThread(threadId);
@@ -72,6 +73,15 @@ export const make = Effect.gen(function* () {
)
)
return;
+ const latestRun = records.runs.reduce<(typeof records.runs)[number] | undefined>(
+ (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest),
+ undefined,
+ );
+ // A late update for an earlier run replaced the latest run's timer, so take it over.
+ if (latestRun?.status === "completed" && latestRun.id !== runId) {
+ runId = latestRun.id;
+ continue;
+ }
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const window = idleCompactionWindow(records, nowMs);
if (window?.runId !== runId) return;
diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
index 905c3f60d33fc..acf9fdbc5306d 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
@@ -651,6 +651,55 @@ it.effect(
}).pipe(Effect.provide(layerTest)),
);
+it.effect("keeps the store's read failure as the cause when a command projection fails", () =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const sql = yield* SqlClient.SqlClient;
+ const now = DateTime.formatIso(yield* DateTime.now);
+ const unreadableMessage = ThreadId.make("thread:control-unreadable-message");
+ const unreadableRequest = ThreadId.make("thread:control-unreadable-request");
+ for (const threadId of [unreadableMessage, unreadableRequest]) {
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`create:${threadId}`),
+ threadId,
+ projectId: ProjectId.make("project:control-unreadable"),
+ title: "Unreadable",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdBy: "user",
+ creationSource: "web",
+ });
+ }
+ yield* sql`INSERT INTO orchestration_v2_projection_messages
+ (message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json)
+ VALUES ('obsolete-user', ${unreadableMessage}, NULL, NULL, 'user', 0, ${now}, ${now}, '{"obsolete":true}')`;
+ yield* sql`INSERT INTO orchestration_v2_projection_runtime_requests
+ (runtime_request_id, thread_id, node_id, kind, status, created_at, payload_json)
+ VALUES ('request:unreadable', ${unreadableRequest}, 'node:unreadable', 'user-input', 'pending', ${now}, '{}')`;
+ for (const command of [
+ {
+ type: "queue.resume",
+ commandId: CommandId.make("resume-unreadable"),
+ threadId: unreadableMessage,
+ },
+ {
+ type: "thread.user-input.dismiss",
+ commandId: CommandId.make("dismiss-unreadable"),
+ threadId: unreadableRequest,
+ requestId: RuntimeRequestId.make("request:unreadable"),
+ },
+ ] as const) {
+ const failure = yield* orchestrator.dispatch(command).pipe(Effect.flip);
+ assert.instanceOf(failure, Orchestrator.OrchestratorProjectionError);
+ assert.instanceOf(failure.cause, ProjectionStore.ProjectionStoreReadError);
+ }
+ }).pipe(Effect.provide(layerTest)),
+);
+
it.effect("implements a proposed plan that the command projection leaves out", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index c7bc6fc64b0ab..4618ccded245f 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -48,6 +48,7 @@ import {
type OrchestrationV2ProviderThread,
type OrchestrationV2ProviderTurn,
type OrchestrationV2RemoteTaskChild,
+ type OrchestrationV2ModelSwitch,
type OrchestrationV2Run,
type OrchestrationV2RunAttempt,
type OrchestrationV2ThreadShell,
@@ -73,8 +74,10 @@ import {
TrimmedNonEmptyString,
} from "@t3tools/contracts";
import { modelSelectionsEqual } from "@t3tools/shared/model";
+import { modelSwitchSince } from "./modelSwitch.ts";
import { recordThreadOpen } from "@t3tools/shared/paletteSearch";
import {
+ backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
pendingBackgroundTurnItems,
} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
@@ -508,6 +511,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
case "thread.unpin":
case "thread.pin.reorder":
case "thread.active.reorder":
+ case "thread.tag.set":
case "thread.visit":
case "thread.mark-unread":
case "thread.metadata.update":
@@ -2004,6 +2008,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
startedAt: now,
completedAt: now,
updatedAt: now,
+ ...(activeHandoff === null ? modelSwitchSince(projection, queuedRun.modelSelection) : {}),
};
const handoffTurnItem: OrchestrationV2TurnItem | null =
activeHandoff === null
@@ -2976,6 +2981,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
| "thread.pin"
| "thread.unpin"
| "thread.pin.reorder"
+ | "thread.tag.set"
| "thread.active.reorder"
| "thread.mark-unread"
| "thread.metadata.update"
@@ -3511,6 +3517,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
updatedAt: thread.updatedAt,
};
}
+ case "thread.tag.set": {
+ const current = thread.tags ?? [];
+ return {
+ ...thread,
+ tags: command.tagged
+ ? current.includes(command.tag)
+ ? current
+ : [...current, command.tag]
+ : current.filter((tag) => tag !== command.tag),
+ };
+ }
case "thread.mark-unread": {
// Retire the wake before rewinding the visit, preserving its settlement timer.
const snoozeExpired =
@@ -3882,6 +3899,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return "thread.pin-reordered" as const;
case "thread.active.reorder":
return "thread.active-reordered" as const;
+ case "thread.tag.set":
+ return "thread.tags-updated" as const;
case "thread.mark-unread":
return "thread.marked-unread" as const;
case "thread.metadata.update":
@@ -4717,6 +4736,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
readonly providerTurnId: typeof providerTurn.id | null;
readonly providerThreadId: OrchestrationV2ProviderThread["id"];
readonly providerInstanceId: ProviderInstanceId;
+ readonly modelSwitch?: OrchestrationV2ModelSwitch | undefined;
}) {
const message: OrchestrationV2ConversationMessage = {
createdBy: input.createdBy,
@@ -4772,6 +4792,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: input.text,
attachments: input.attachments,
...(input.context ? { context: input.context } : {}),
+ ...(messageInput.modelSwitch === undefined
+ ? {}
+ : { modelSwitch: messageInput.modelSwitch }),
};
yield* emitEvent({
type: "message.updated",
@@ -5232,6 +5255,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
providerTurnId: null,
providerThreadId: restartProviderThread.id,
providerInstanceId: input.modelSelection.instanceId,
+ // Measured on the pre-restart projection, so the rewritten run is not its own baseline.
+ ...(restartHandoff === null ? modelSwitchSince(input.projection, input.modelSelection) : {}),
});
const interruptedAttemptId = targetRun.activeAttemptId;
if (interruptedAttemptId === null) {
@@ -5404,7 +5429,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
: now,
updatedAt: now,
};
- yield* emit(
+ const event = yield* emit(
events,
command,
)({
@@ -5414,7 +5439,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: thread,
});
- projection = yield* getProjectionWithPendingEvents(command.threadId, events);
+ projection = applyToProjection(projection, event);
}
// Background wakes and delegated completions continue work the user already snoozed, so
// only a message someone actually sent ends the snooze.
@@ -5428,7 +5453,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
snoozedAt: null,
updatedAt: now,
};
- yield* emit(
+ const event = yield* emit(
events,
command,
)({
@@ -5438,7 +5463,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: thread,
});
- projection = yield* getProjectionWithPendingEvents(command.threadId, events);
+ projection = applyToProjection(projection, event);
}
const userMessages = projection.messages.filter((message) => message.role === "user");
const onlyMaintenanceHistory =
@@ -5458,7 +5483,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
titleRegeneration: { requestId: command.commandId, startedAt: now },
updatedAt: now,
};
- yield* emit(
+ const event = yield* emit(
events,
command,
)({
@@ -5475,7 +5500,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
messageId: command.messageId,
}),
]);
- projection = yield* getProjectionWithPendingEvents(command.threadId, events);
+ projection = applyToProjection(projection, event);
}
const modelSelection = command.modelSelection ?? projection.thread.modelSelection;
let dispatchMode = resolveMessageDispatchIntent(
@@ -6345,6 +6370,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: dispatchText,
...(command.context ? { context: command.context } : {}),
attachments: command.attachments,
+ ...(run.contextHandoffId === null ? modelSwitchSince(projection, modelSelection) : {}),
};
const preparationTurnItem: OrchestrationV2TurnItem | null =
dispatchMode.type === "defer_start"
@@ -7096,6 +7122,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
text: dispatchText,
...(command.context ? { context: command.context } : {}),
attachments: command.attachments,
+ ...(run.contextHandoffId === null
+ ? modelSwitchSince(projection, modelSelection, sourceRun?.modelSelection)
+ : {}),
};
const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff;
const handoffSourceRuns =
@@ -8802,7 +8831,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const context = yield* projectionStore
.getRuntimeResponseContext(command.threadId, command.requestId)
.pipe(
- Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })),
+ Effect.mapError(
+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
+ ),
);
const runtimeRequest = context.request;
if (runtimeRequest === undefined) {
@@ -9215,7 +9246,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const request = yield* projectionStore
.getRuntimeRequest(command.threadId, command.requestId)
.pipe(
- Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })),
+ Effect.mapError(
+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
+ ),
);
if (request === undefined || request.status !== "pending") {
return yield* new OrchestratorDispatchError({
@@ -9252,24 +9285,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
events: Ref.Ref>,
effects: Ref.Ref>,
) {
- const projection = yield* projectionStore
- .getThreadRecords(
- command.threadId,
- [
- "runs",
- "nodes",
- "attempts",
- "messages",
- "providerThreads",
- "providerTurns",
- "providerSessions",
- "turnItems",
- "runtimeRequests",
- "subagents",
- ],
- { turnItemTypes: [], messageRoles: ["user"] },
- )
- .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })));
+ const projection = yield* loadProjectionForCommand(
+ command,
+ [
+ "runs",
+ "nodes",
+ "attempts",
+ "messages",
+ "providerThreads",
+ "providerTurns",
+ "providerSessions",
+ "turnItems",
+ "runtimeRequests",
+ "subagents",
+ ],
+ // User messages measure a restart's model switch.
+ { turnItemTypes: ["user_message"], messageRoles: ["user"] },
+ );
if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
@@ -9463,9 +9495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
command: Extract,
events: Ref.Ref>,
) {
- const projection = yield* projectionStore
- .getThreadRecords(command.threadId, ["runs", "messages"], { messageRoles: ["user"] })
- .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })));
+ const projection = yield* loadProjectionForCommand(command, ["runs", "messages"], {
+ messageRoles: ["user"],
+ });
const queuedRuns = queuedRunsInDeliveryOrder(projection);
const moving = queuedRuns.find((run) => run.id === command.runId);
if (moving === undefined) {
@@ -9549,13 +9581,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
command: Extract,
events: Ref.Ref>,
) {
- const projection = yield* projectionStore
- .getThreadRecords(
- command.threadId,
- ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"],
- { turnItemTypes: [], messageRoles: ["user"] },
- )
- .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })));
+ const projection = yield* loadProjectionForCommand(
+ command,
+ ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"],
+ { turnItemTypes: [], messageRoles: ["user"] },
+ );
const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId);
if (queuedRun === undefined || queuedRun.status !== "queued") {
return yield* new OrchestratorDispatchError({
@@ -9660,12 +9690,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: `Queued run ${command.runId} cannot be edited to an empty message.`,
});
}
- const projection = yield* projectionStore
- .getThreadRecords(command.threadId, ["runs", "messages", "turnItems"], {
- turnItemTypes: ["user_message"],
- messageRoles: ["user"],
- })
- .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })));
+ const projection = yield* loadProjectionForCommand(command, ["runs", "messages", "turnItems"], {
+ turnItemTypes: ["user_message"],
+ messageRoles: ["user"],
+ });
const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId);
if (queuedRun === undefined || queuedRun.status !== "queued") {
return yield* new OrchestratorDispatchError({
@@ -9763,7 +9791,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
.getThreadRecords(commandThreadId(command), fields, filter)
.pipe(
Effect.mapError(
- () => new OrchestratorProjectionError({ threadId: commandThreadId(command) }),
+ (cause) => new OrchestratorProjectionError({ threadId: commandThreadId(command), cause }),
),
);
@@ -10371,7 +10399,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
rootNode === undefined ||
run.activeAttemptId !== attempt.id ||
run.status !== "running" ||
- attempt.status !== "running"
+ attempt.status === "pending"
)
return;
const emitEvent = emit(input.events, input.command);
@@ -10393,15 +10421,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
},
});
}
- yield* emitEvent({
- ...base,
- type: "run-attempt.updated",
- payload: {
- ...attempt,
- status: "interrupted",
- completedAt: input.now,
- },
- });
+ // An attempt that already ended keeps its status; only its run was left running.
+ if (attempt.status === "running") {
+ yield* emitEvent({
+ ...base,
+ type: "run-attempt.updated",
+ payload: {
+ ...attempt,
+ status: "interrupted",
+ completedAt: input.now,
+ },
+ });
+ }
const providerThread = input.projection.providerThreads.find(
(candidate) => candidate.id === input.providerTurn.providerThreadId,
);
@@ -12973,6 +13004,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "thread.unpin":
case "thread.pin.reorder":
case "thread.active.reorder":
+ case "thread.tag.set":
case "thread.mark-unread":
case "thread.metadata.update":
case "thread.pull-request.link":
@@ -13614,14 +13646,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// A child whose run ends while its background tasks or its own subagents still run has no
// result yet. When that work settles there is no run update to finalize it, so these events
- // do it. A background roster counts once it empties after it was seen non-empty.
+ // do it. A background roster counts once it stops holding after it was seen holding.
const providerThreadsWithBackgroundWork = new Set();
const settlesChildWork = (stored: OrchestrationV2StoredEvent) => {
const { event } = stored;
if (event.type === "subagent.updated")
return !isOrchestrationV2WorkActive(event.payload.status);
if (event.type !== "provider-thread.updated") return false;
- if ((event.payload.pendingBackgroundTasks?.length ?? 0) > 0) {
+ if (backgroundWorkHoldsCompletion(event.payload.pendingBackgroundTasks ?? [])) {
providerThreadsWithBackgroundWork.add(event.payload.id);
return false;
}
diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts
index aad9d355924be..211fb2ad8e7a3 100644
--- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts
+++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts
@@ -219,6 +219,8 @@ export const layer: Layer.Layer<
];
// Read-state events preserve updatedAt and supersede only each other. Activity events supersede both groups.
const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"];
+ // Older clients skip tag events, so these supersede only each other.
+ const TAG_THREAD_EVENT_TYPE = "thread.tags-updated";
const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES);
const readStateThreadEventTypes = new Set(READ_STATE_THREAD_EVENT_TYPES);
@@ -243,6 +245,7 @@ export const layer: Layer.Layer<
let throughReceiptRowId = bounds[0]?.receipt_row_id ?? 0;
const retainedThreadIds = new Set();
const retainedReadStateThreadIds = new Set();
+ const retainedTagThreadIds = new Set();
// Retain each key's oldest candidate, deleting it only when an older event appears.
const oldestCandidateByEntityKey = new Map();
let deletedEventCount = 0;
@@ -316,6 +319,13 @@ export const layer: Layer.Layer<
) {
obsolete.push(row.sequence);
} else retainedReadStateThreadIds.add(row.stream_id);
+ } else if (
+ row.aggregate_kind === "thread" &&
+ row.event_type === TAG_THREAD_EVENT_TYPE
+ ) {
+ if (retainedThreadIds.has(row.stream_id) || retainedTagThreadIds.has(row.stream_id)) {
+ obsolete.push(row.sequence);
+ } else retainedTagThreadIds.add(row.stream_id);
} else if (
row.event_type === "thread.pull-request-link-updated" &&
retainedThreadIds.has(row.stream_id)
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index cd92632ec7929..797de5b5e0dff 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -3439,6 +3439,216 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
}),
);
+ it.effect("reads a run's turn-start items by thread and run, not by walking thread history", () =>
+ Effect.gen(function* () {
+ const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
+ const sql = yield* SqlClient.SqlClient;
+ const now = yield* DateTime.now;
+ const nowIso = DateTime.formatIso(now);
+ const threadId = ThreadId.make("thread:turn-start-items");
+ const otherThreadId = ThreadId.make("thread:turn-start-items:other");
+ const selectedRunId = RunId.make("run:turn-start-items:selected");
+ const emptyRunId = RunId.make("run:turn-start-items:empty");
+ const siblingRunId = RunId.make("run:turn-start-items:sibling");
+
+ for (const id of [threadId, otherThreadId]) {
+ yield* projectionStore.apply({
+ id: EventId.make(`event:${id}:created`),
+ type: "thread.created",
+ threadId: id,
+ occurredAt: now,
+ payload: {
+ createdBy: "user",
+ creationSource: "web",
+ id,
+ projectId: ProjectId.make("project:turn-start-items"),
+ title: id,
+ providerInstanceId,
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
+ forkedFrom: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ },
+ });
+ }
+ for (const [ordinal, runId] of [selectedRunId, emptyRunId, siblingRunId].entries()) {
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_runs (
+ run_id, thread_id, ordinal, provider, provider_thread_id, status,
+ requested_at, completed_at, payload_json
+ ) VALUES (
+ ${runId}, ${threadId}, ${ordinal + 1}, 'codex', NULL, 'completed', ${nowIso}, ${nowIso},
+ ${encodeUnknownJsonString({
+ id: runId,
+ threadId,
+ ordinal: ordinal + 1,
+ providerInstanceId,
+ modelSelection,
+ providerThreadId: null,
+ userMessageId: `message:${runId}`,
+ rootNodeId: `node:${runId}`,
+ activeAttemptId: null,
+ status: "completed",
+ requestedAt: nowIso,
+ startedAt: nowIso,
+ completedAt: nowIso,
+ checkpointId: null,
+ contextHandoffId: null,
+ })}
+ )
+ `;
+ }
+
+ const insertItem = (
+ id: string,
+ itemThreadId: ThreadId,
+ runId: string | null,
+ ordinal: number,
+ ) =>
+ sql`
+ INSERT INTO orchestration_v2_projection_turn_items (
+ turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
+ parent_item_id, ordinal, type, status, updated_at, payload_json
+ ) VALUES (
+ ${id}, ${itemThreadId}, ${runId}, NULL, NULL, NULL, NULL, ${ordinal},
+ 'command_execution', 'completed', ${nowIso},
+ ${encodeUnknownJsonString({
+ id,
+ threadId: itemThreadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal,
+ status: "completed",
+ title: id,
+ input: "echo",
+ output: "ok",
+ exitCode: 0,
+ startedAt: nowIso,
+ completedAt: nowIso,
+ updatedAt: nowIso,
+ type: "command_execution",
+ })}
+ )
+ `;
+ const addHistory = (from: number, to: number) => sql`
+ WITH RECURSIVE history(position) AS (
+ SELECT ${from} UNION ALL SELECT position + 1 FROM history WHERE position < ${to}
+ )
+ INSERT INTO orchestration_v2_projection_turn_items (
+ turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
+ parent_item_id, ordinal, type, status, updated_at, payload_json
+ )
+ SELECT 'item:turn-start-items:history:' || position, ${threadId},
+ 'run:turn-start-items:history:' || (position % 100), NULL, NULL, NULL, NULL, position,
+ 'command_execution', 'completed', ${nowIso},
+ json_object(
+ 'id', 'item:turn-start-items:history:' || position,
+ 'threadId', ${threadId},
+ 'runId', 'run:turn-start-items:history:' || (position % 100),
+ 'nodeId', NULL, 'providerThreadId', NULL, 'providerTurnId', NULL,
+ 'nativeItemRef', NULL, 'parentItemId', NULL, 'ordinal', position,
+ 'status', 'completed', 'title', 'history', 'input', 'echo', 'output', 'ok',
+ 'exitCode', 0, 'startedAt', ${nowIso}, 'completedAt', ${nowIso},
+ 'updatedAt', ${nowIso}, 'type', 'command_execution'
+ )
+ FROM history
+ `;
+
+ // Twelve ordinals with two items each, inserted in reverse id order.
+ const selectedIds: Array = [];
+ for (let slot = 1; slot <= 12; slot += 1) {
+ for (const suffix of ["b", "a"]) {
+ yield* insertItem(
+ `item:turn-start-items:selected:${String(slot).padStart(2, "0")}:${suffix}`,
+ threadId,
+ selectedRunId,
+ slot * 100,
+ );
+ }
+ selectedIds.push(
+ `item:turn-start-items:selected:${String(slot).padStart(2, "0")}:a`,
+ `item:turn-start-items:selected:${String(slot).padStart(2, "0")}:b`,
+ );
+ }
+ yield* insertItem("item:turn-start-items:no-run", threadId, null, 100);
+ yield* insertItem("item:turn-start-items:sibling:1", threadId, siblingRunId, 100);
+ yield* insertItem("item:turn-start-items:sibling:2", threadId, siblingRunId, 101);
+ yield* insertItem("item:turn-start-items:other-thread:b", otherThreadId, selectedRunId, 100);
+ yield* insertItem("item:turn-start-items:other-thread:a", otherThreadId, selectedRunId, 100);
+
+ const readItems = Effect.fn("readTurnStartItems")(function* (
+ readThreadId: ThreadId,
+ readRunId: RunId,
+ ) {
+ const { statements, tracer } = traceSqlStatements();
+ const context = yield* projectionStore
+ .getTurnStartContext(readThreadId, readRunId)
+ .pipe(Effect.withTracer(tracer));
+ const lookups = statements.filter((statement) =>
+ statement.includes("FROM orchestration_v2_projection_turn_items"),
+ );
+ assert.lengthOf(lookups, 1);
+ const plan = (yield* sql.unsafe<{ readonly detail: string }>(
+ `EXPLAIN QUERY PLAN ${lookups[0]}`,
+ [readThreadId, readRunId],
+ )).map((row) => row.detail);
+ return { ids: context.turnItems.map((item) => item.id), plan };
+ });
+
+ const lookupPlans: Array> = [];
+ for (const [from, to] of [
+ [1, 1_000],
+ [1_001, 10_000],
+ ] as const) {
+ yield* addHistory(from, to);
+ const { ids, plan } = yield* readItems(threadId, selectedRunId);
+ assert.deepEqual(ids, selectedIds);
+ lookupPlans.push(plan);
+ }
+
+ assert.deepEqual((yield* readItems(threadId, emptyRunId)).ids, []);
+ assert.deepEqual(
+ (yield* readItems(threadId, RunId.make("run:turn-start-items:missing"))).ids,
+ [],
+ );
+ assert.deepEqual((yield* readItems(threadId, siblingRunId)).ids, [
+ "item:turn-start-items:sibling:1",
+ "item:turn-start-items:sibling:2",
+ ]);
+ assert.deepEqual((yield* readItems(otherThreadId, selectedRunId)).ids, [
+ "item:turn-start-items:other-thread:a",
+ "item:turn-start-items:other-thread:b",
+ ]);
+ assert.deepEqual((yield* readItems(otherThreadId, siblingRunId)).ids, []);
+
+ for (const plan of lookupPlans) {
+ const lookup = plan.filter((detail) =>
+ detail.includes("orchestration_v2_projection_turn_items"),
+ );
+ assert.lengthOf(lookup, 1);
+ assert.match(
+ lookup[0]!,
+ /^SEARCH .*\((?:thread_id=\? AND )?run_id=\?(?: AND thread_id=\?)?\)$/,
+ );
+ }
+ }),
+ );
+
it.effect(
"counts each shell's own runless and live-run items, skipping rolled back and missing runs",
() =>
@@ -6156,6 +6366,18 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
});
assert.deepEqual(followingPage.items, targetAfterRollback.visibleTurnItems.slice(2));
assert.isFalse(followingPage.hasMore);
+ const tracedTimelinePage = (limit: number) =>
+ Effect.gen(function* () {
+ const { statements, tracer } = traceSqlStatements();
+ const page = yield* projectionStore
+ .getTimelinePage(targetThreadId, { view: "activity", limit })
+ .pipe(Effect.withTracer(tracer));
+ return { page, statements };
+ });
+ const singleItemPage = yield* tracedTimelinePage(1);
+ const fullPage = yield* tracedTimelinePage(10);
+ assert.deepEqual(fullPage.page.items, targetAfterRollback.visibleTurnItems);
+ assert.lengthOf(fullPage.statements, singleItemPage.statements.length);
assert.deepEqual(
targetAfterRollback.visibleTurnItems.map((row) => [
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 3f96fe7d0818e..383f6fe666711 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -808,6 +808,7 @@ export function applyToProjection(
};
// Reordering and PR refreshes preserve the orchestrator's activity timestamp.
case "thread.active-reordered":
+ case "thread.tags-updated":
case "thread.pull-request-synced":
return {
...base,
@@ -1419,12 +1420,24 @@ function secretRequestAsPendingInput(
/**
* A secret card is answered while its agent waits in the run. A link card's
- * agent asked without waiting, so the card waits on the user after its run.
+ * agent asked without waiting, so the card waits on the user after its run,
+ * until the user writes in the thread again and so has moved past it.
*/
-const waitsOnUser = (item: OrchestrationV2TurnItem, liveRunIds: ReadonlySet) =>
- item.type === "link_request" ||
+const waitsOnUser = (
+ item: OrchestrationV2TurnItem,
+ liveRunIds: ReadonlySet,
+ latestUserAuthoredMessageAt: DateTime.Utc | null,
+) =>
+ (item.type === "link_request" && linkCardStillAsks(item, latestUserAuthoredMessageAt)) ||
(item.type === "secret_request" && item.runId !== null && liveRunIds.has(item.runId));
+const linkCardStillAsks = (
+ item: OrchestrationV2TurnItem,
+ latestUserAuthoredMessageAt: DateTime.Utc | null,
+) =>
+ latestUserAuthoredMessageAt === null ||
+ !DateTime.isGreaterThan(latestUserAuthoredMessageAt, item.startedAt ?? item.updatedAt);
+
export function threadShellFromProjection(
projection: OrchestrationV2ThreadProjection,
): OrchestrationV2ThreadShell {
@@ -1460,23 +1473,29 @@ export function threadShellFromProjection(
DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt),
)[0] ?? null,
);
+ const userMessages = projection.messages
+ .filter((message) => message.role === "user" && idleCompactionOf(message) === undefined)
+ .toSorted(
+ (left, right) =>
+ DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
+ );
+ const latestUserAuthoredMessageAt =
+ userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null;
if (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) {
pendingRuntimeRequest =
secretRequestAsPendingInput(
projection.turnItems
- .filter((item) => item.status === "waiting" && waitsOnUser(item, liveRunIds))
+ .filter(
+ (item) =>
+ item.status === "waiting" &&
+ waitsOnUser(item, liveRunIds, latestUserAuthoredMessageAt),
+ )
.toSorted(
(left, right) =>
DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
)[0] ?? null,
) ?? pendingRuntimeRequest;
}
- const userMessages = projection.messages
- .filter((message) => message.role === "user" && idleCompactionOf(message) === undefined)
- .toSorted(
- (left, right) =>
- DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
- );
const latestUserMessage = userMessages[0] ?? null;
const pendingBackgroundTasks = derivePendingBackgroundWork({
latestRun,
@@ -1545,8 +1564,7 @@ export function threadShellFromProjection(
// initial hydration and streaming updates independent of transcript size.
latestVisibleMessage: null,
latestUserMessageAt: latestUserMessage?.updatedAt ?? null,
- latestUserAuthoredMessageAt:
- userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null,
+ latestUserAuthoredMessageAt,
hasActionableProposedPlan: projection.plans.some(
(plan) => plan.kind === "proposed_plan" && plan.status === "active",
),
@@ -1573,6 +1591,7 @@ export function threadShellFromProjection(
autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null,
pinOrderKey: projection.thread.pinOrderKey ?? null,
+ ...(projection.thread.tags?.length ? { tags: projection.thread.tags } : {}),
lastVisitedAt: projection.thread.lastVisitedAt,
opens: projection.thread.opens ?? null,
titleRegeneration: projection.thread.titleRegeneration ?? null,
@@ -1837,6 +1856,7 @@ function shellFromState(input: {
autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null,
pinOrderKey: input.state.thread.pinOrderKey ?? null,
+ ...(input.state.thread.tags?.length ? { tags: input.state.thread.tags } : {}),
lastVisitedAt: input.state.thread.lastVisitedAt,
opens: input.state.thread.opens ?? null,
titleRegeneration: input.state.thread.titleRegeneration ?? null,
@@ -1895,6 +1915,7 @@ export const layer: Layer.Layer =
case "thread.unpinned":
case "thread.pin-reordered":
case "thread.active-reordered":
+ case "thread.tags-updated":
case "thread.visited":
case "thread.marked-unread":
case "thread.metadata-updated":
@@ -2707,6 +2728,7 @@ export const layer: Layer.Layer =
event.type !== "thread.unpinned" &&
event.type !== "thread.pin-reordered" &&
event.type !== "thread.active-reordered" &&
+ event.type !== "thread.tags-updated" &&
event.type !== "thread.visited" &&
event.type !== "thread.marked-unread" &&
event.type !== "thread.metadata-updated" &&
@@ -4314,6 +4336,8 @@ export const layer: Layer.Layer =
`.pipe(Effect.flatMap(decodeRows(decodeContextTransferPayload, threadId)));
const turnItems = yield* sql`
SELECT payload_json FROM orchestration_v2_projection_turn_items
+ -- Look up one run's items by key, not by walking the thread's whole history in ordinal order.
+ INDEXED BY orchestration_v2_projection_turn_items_run_ordinal_idx
WHERE thread_id = ${threadId} AND run_id = ${runId} ORDER BY ordinal ASC, turn_item_id ASC
`.pipe(Effect.flatMap(decodeRows(decodeTurnItemPayload, threadId)));
const providerSessions = yield* sql`
@@ -5743,6 +5767,23 @@ export const layer: Layer.Layer =
: row.sourceItemId === options.itemId,
);
const page = matching.slice(0, options.limit);
+ const sources = yield* encodeTimelineSources(
+ page
+ .filter((row) => row.synthetic === undefined)
+ .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })),
+ );
+ const payloads = yield* sql<{
+ thread_id: string;
+ turn_item_id: string;
+ payload_json: string;
+ }>`
+ SELECT item.thread_id, item.turn_item_id, item.payload_json
+ FROM orchestration_v2_projection_turn_items item JOIN json_each(${sources}) wanted
+ ON item.thread_id = json_extract(wanted.value, '$.threadId')
+ AND item.turn_item_id = json_extract(wanted.value, '$.id')`;
+ const byIdentity = new Map(
+ payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]),
+ );
const items = yield* Effect.forEach(page, (row) =>
Effect.gen(function* () {
if (row.synthetic !== undefined)
@@ -5753,19 +5794,18 @@ export const layer: Layer.Layer =
visibility: row.visibility,
item: row.synthetic,
};
- const payloads =
- yield* sql`SELECT payload_json FROM orchestration_v2_projection_turn_items
- WHERE thread_id = ${row.sourceThreadId} AND turn_item_id = ${row.sourceItemId}`;
- const decoded = yield* decodeRows(
- decodeTurnItemPayload,
- row.sourceThreadId,
- )(payloads);
- const item = decoded[0];
- if (item === undefined)
+ const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`);
+ if (payload === undefined)
return yield* new ProjectionStoreReadError({
threadId,
cause: "Timeline item disappeared during snapshot",
});
+ const item = yield* decodeTurnItemPayload(payload).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProjectionStoreReadError({ threadId: row.sourceThreadId, cause }),
+ ),
+ );
return {
position: row.position,
sourceThreadId: row.sourceThreadId,
@@ -6023,6 +6063,7 @@ export const layer: Layer.Layer =
WHERE message.thread_id = t.thread_id
AND message.role = 'user'
AND json_extract(message.payload_json, '$.createdBy') = 'user'
+ AND NOT ${isIdleCompactionMessage("message")}
ORDER BY message.updated_at DESC, message.message_id DESC
LIMIT 1
) AS latest_user_authored_message_at,
@@ -6277,6 +6318,7 @@ export const layer: Layer.Layer =
FROM orchestration_v2_projection_messages message
WHERE message.thread_id = t.thread_id AND message.role = 'user'
AND json_extract(message.payload_json, '$.createdBy') = 'user'
+ AND NOT ${isIdleCompactionMessage("message")}
ORDER BY message.updated_at DESC, message.message_id DESC
LIMIT 1
) AS latest_user_authored_message_at
@@ -6472,6 +6514,10 @@ export const layer: Layer.Layer =
pendingTurnItemsByThreadId,
} = input;
const thread = yield* decodeThreadPayload(row.payload_json);
+ const latestUserAuthoredMessageAt =
+ row.latest_user_authored_message_at === null
+ ? null
+ : DateTime.makeUnsafe(row.latest_user_authored_message_at);
const waitingCards = [
row.pending_secret_request_payload_json === null
? null
@@ -6479,7 +6525,12 @@ export const layer: Layer.Layer =
row.pending_link_request_payload_json === null
? null
: yield* decodeTurnItemPayload(row.pending_link_request_payload_json),
- ].filter((item) => item !== null);
+ ]
+ .filter((item) => item !== null)
+ .filter(
+ (item) =>
+ item.type !== "link_request" || linkCardStillAsks(item, latestUserAuthoredMessageAt),
+ );
let pendingRuntimeRequest =
row.pending_request_payload_json !== null
? pendingRuntimeRequestSummary(
@@ -6598,10 +6649,7 @@ export const layer: Layer.Layer =
row.latest_user_message_at === null
? null
: DateTime.makeUnsafe(row.latest_user_message_at),
- latestUserAuthoredMessageAt:
- row.latest_user_authored_message_at === null
- ? null
- : DateTime.makeUnsafe(row.latest_user_authored_message_at),
+ latestUserAuthoredMessageAt,
hasActionableProposedPlan: row.has_actionable_proposed_plan === 1,
pendingBackgroundTasks,
providerInstanceHistory: providerInstanceHistoryForShell({
diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
index 7f1006f1c5ec5..927a9060dd4f4 100644
--- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
@@ -45,6 +45,7 @@ import * as EventStore from "./EventStore.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
+import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
@@ -325,12 +326,14 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
);
it.effect("records accepted billed turn usage once without billing the context window", () => {
- const recorded: Array>> = [];
- const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, {
- record: (properties: Readonly>) =>
+ const recorded: Array = [];
+ const layerAnalytics = Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, {
+ runStarted: () => Effect.void,
+ turnCompleted: (report) =>
Effect.sync(() => {
- recorded.push(properties);
+ recorded.push(report);
}),
+ runSettled: () => Effect.void,
});
return Effect.gen(function* () {
const now = yield* DateTime.now;
@@ -413,23 +416,9 @@ it.effect("records accepted billed turn usage once without billing the context w
assert.isEmpty(ignored);
assert.lengthOf(recorded, 1);
assert.deepEqual(recorded[0], {
- provider: CODEX_DRIVER,
- terminalStatus: "completed",
- usageStatus: "complete",
- usageScope: "main_agent",
- hasSubagents: false,
- inputTokens: 40,
- cachedInputTokens: 30,
- outputTokens: 10,
- model: modelSelection.model,
- mixedModels: false,
- runtimeMode: "full-access",
- interactionMode: "default",
- createdBy: "agent",
- creationSource: "provider",
- scheduled: false,
- subagent: true,
- durationMs: 120,
+ driver: CODEX_DRIVER,
+ providerTurn,
+ context: input.analyticsContext,
});
}).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics))));
});
diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts
index 784449209e290..f27580d909aff 100644
--- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts
+++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts
@@ -5,14 +5,9 @@ import {
OrchestrationV2StoredEvent,
type OrchestrationV2PlanArtifact,
type OrchestrationV2Run,
- type OrchestrationV2ProviderTurn,
type OrchestrationV2Subagent,
type OrchestrationV2ThreadProjection,
type ModelSelection,
- type OrchestrationV2Actor,
- type OrchestrationV2CreationSource,
- type RuntimeMode,
- type ProviderInteractionMode,
ProviderInstanceId,
ProviderSessionId,
ProviderThreadId,
@@ -31,7 +26,11 @@ import * as SqlError from "effect/sql/SqlError";
import { AgentScope } from "@t3tools/shared/AgentScope";
import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
-import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
+import { modelSelectionsEqual } from "@t3tools/shared/model";
+import {
+ ProviderTurnAnalytics,
+ type ProviderTurnAnalyticsContext,
+} from "../telemetry/ProviderUsageAnalytics.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as EventSink from "./EventSink.ts";
@@ -84,76 +83,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([
]);
export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type;
-export interface ProviderTurnAnalyticsContext {
- readonly modelSelection: ModelSelection;
- readonly runtimeMode?: RuntimeMode;
- readonly interactionMode?: ProviderInteractionMode;
- readonly origin?: ProviderTurnOrigin;
-}
-
-/**
- * Where the run's starting message came from. Most turns start without a
- * person (provider continuations, agent sends, delegated subagents), so
- * reports need this to tell user turns apart.
- */
-export interface ProviderTurnOrigin {
- readonly createdBy: OrchestrationV2Actor;
- readonly creationSource: OrchestrationV2CreationSource;
- readonly scheduled: boolean;
- readonly subagent: boolean;
-}
-
-export class ProviderTurnAnalytics extends Context.Reference<{
- readonly record: (properties: Readonly>) => Effect.Effect;
-}>("t3/orchestration-v2/ProviderTurnAnalytics", {
- defaultValue: () => ({ record: () => Effect.void }),
-}) {}
-
-// Coder: no provider turn analytics; `ProviderTurnAnalytics` keeps its no-op default.
-
-function providerTurnAnalyticsProperties(input: {
- readonly driver: ProviderAdapter.ProviderAdapterV2Event["driver"];
- readonly providerTurn: OrchestrationV2ProviderTurn;
- readonly context?: ProviderTurnAnalyticsContext;
-}): Readonly> {
- const usage = input.providerTurn.turnTokenUsage;
- const modelSelection = input.context?.modelSelection;
- const effort = modelSelection
- ? (getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
- getModelSelectionStringOptionValue(modelSelection, "effort"))
- : undefined;
- return {
- provider: input.driver,
- terminalStatus: input.providerTurn.status,
- usageStatus: usage?.usageStatus ?? "unavailable",
- usageScope: usage?.usageScope ?? "main_agent",
- ...(usage ? { hasSubagents: usage.hasSubagents } : {}),
- ...(usage?.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
- ...(usage?.cachedInputTokens === undefined
- ? {}
- : { cachedInputTokens: usage.cachedInputTokens }),
- ...(usage?.cacheCreationTokens === undefined
- ? {}
- : { cacheCreationTokens: usage.cacheCreationTokens }),
- ...(usage?.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
- ...(usage?.reasoningTokens === undefined ? {} : { reasoningTokens: usage.reasoningTokens }),
- ...(modelSelection ? { model: modelSelection.model, mixedModels: false } : {}),
- ...(effort ? { effort } : {}),
- ...(input.context?.runtimeMode ? { runtimeMode: input.context.runtimeMode } : {}),
- ...(input.context?.interactionMode ? { interactionMode: input.context.interactionMode } : {}),
- ...input.context?.origin,
- ...(input.providerTurn.startedAt && input.providerTurn.completedAt
- ? {
- durationMs: Math.max(
- 0,
- DateTime.toEpochMillis(input.providerTurn.completedAt) -
- DateTime.toEpochMillis(input.providerTurn.startedAt),
- ),
- }
- : {}),
- };
-}
-
type TodoListPlan = Extract;
function withPlanStepDurations(
@@ -878,15 +807,13 @@ export const layer: Layer.Layer<
const oldest = completedTurnAnalytics.values().next().value;
if (oldest !== undefined) completedTurnAnalytics.delete(oldest);
}
- yield* analytics.record(
- providerTurnAnalyticsProperties({
- driver: input.event.driver,
- providerTurn,
- ...(input.analyticsContext === undefined
- ? {}
- : { context: input.analyticsContext }),
- }),
- );
+ yield* analytics.turnCompleted({
+ driver: input.event.driver,
+ providerTurn,
+ ...(input.analyticsContext === undefined
+ ? {}
+ : { context: input.analyticsContext }),
+ });
}),
),
(effect) =>
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
index 05fc1b8545bb2..c9311433eecab 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
@@ -51,6 +51,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"
import * as ProviderSessionManager from "./ProviderSessionManager.ts";
import * as ProviderTurnStart from "./ProviderTurnStartService.ts";
import * as RunExecutionService from "./RunExecutionService.ts";
+import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as RuntimePolicy from "./RuntimePolicy.ts";
const isDomainEvent = Schema.is(OrchestrationV2DomainEvent);
@@ -301,6 +302,7 @@ function makeLocalCommandHarness(input: {
readonly switchedProvider?: boolean;
};
}) {
+ const settledRuns: Array = [];
const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z");
const threadId = ThreadId.make("thread-native-account-command");
const runId = RunId.make("run-native-account-command");
@@ -947,6 +949,11 @@ function makeLocalCommandHarness(input: {
}),
Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }),
Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }),
+ Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, {
+ runStarted: () => Effect.void,
+ turnCompleted: () => Effect.void,
+ runSettled: (report) => Effect.sync(() => void settledRuns.push(report.status)),
+ }),
Layer.mock(RuntimePolicy.RuntimePolicyV2)({
resolve: resolveRuntimePolicy,
}),
@@ -954,6 +961,7 @@ function makeLocalCommandHarness(input: {
),
);
return {
+ settledRuns,
getThreadRecords,
getTurnStartContext,
setProjection: (next: OrchestrationV2ThreadProjection) => {
@@ -1578,6 +1586,7 @@ effectIt.effect("terminalizes a starting run when its provider session cannot op
);
const projection = harness.projection();
expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null });
+ expect(harness.settledRuns).toEqual(["failed"]);
expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null });
expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null });
expect(projection.turnItems).toMatchObject([
@@ -1667,6 +1676,7 @@ effectIt.effect("does not overwrite a run interrupted while its provider session
expect(harness.startRootRun).not.toHaveBeenCalled();
const projection = harness.projection();
expect(projection.runs.at(-1)?.status).toBe("interrupted");
+ expect(harness.settledRuns).toEqual([]);
expect(projection.attempts[0]?.status).toBe("pending");
expect(projection.nodes[0]?.status).toBe("pending");
expect(projection.turnItems).toEqual([]);
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
index 402b2eaf3e04e..4a1fafd4f8906 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
@@ -33,6 +33,7 @@ import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
import * as ServerSettings from "../serverSettings.ts";
import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
import * as EventSink from "./EventSink.ts";
+import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as ContextHandoffService from "./ContextHandoffService.ts";
import { isActiveRun } from "./CommandPolicy.ts";
import {
@@ -148,6 +149,7 @@ export const layer: Layer.Layer<
ProviderTurnStartServiceV2,
Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
+ const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics;
const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const fileSystem = yield* FileSystem.FileSystem;
@@ -496,13 +498,22 @@ export const layer: Layer.Layer<
} satisfies OrchestrationV2DomainEvent;
}),
);
- yield* eventSink.writeIfRunCurrent({
+ const written = yield* eventSink.writeIfRunCurrent({
threadId: projection.thread.id,
runId,
activeAttemptId: attempt.id,
expectedStatus: "starting",
events,
});
+ // A steering restart that cannot start still ends a run whose earlier turns counted.
+ if (written.committed) {
+ yield* turnAnalytics.runSettled({
+ runId,
+ status,
+ completedAt: now,
+ childrenRunning: false,
+ });
+ }
},
);
const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")(
diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
index ff98b43e70ee6..93ddf77608007 100644
--- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
+++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
@@ -789,6 +789,81 @@ describe("PullRequestSyncReactor", () => {
),
);
+ it.effect("keeps the known mergeability while the host recomputes it", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(Date.parse(NOW));
+ const answers = yield* Ref.make>([
+ "unknown",
+ "mergeable",
+ ]);
+ const fixture = yield* makeHarness({
+ snapshot: makeSnapshot([
+ makeThread("one", {
+ pullRequests: [makeLink(42, { mergeability: "conflicting" })],
+ }),
+ ]),
+ summary: (input) =>
+ Ref.modify(answers, ([next = "mergeable", ...rest]) => [
+ makeSummary(input, { mergeability: next }),
+ rest,
+ ]),
+ });
+
+ yield* Effect.gen(function* () {
+ const reactor = yield* startAndSweep(fixture);
+ assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []);
+
+ // An unchanged old PR uses main's five-minute quiet polling cadence.
+ yield* TestClock.adjust("5 minutes");
+ yield* Queue.take(fixture.snapshotReads);
+ yield* reactor.drain;
+ assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
+
+ const commands = yield* Ref.get(fixture.syncCommands);
+ assert.deepStrictEqual(
+ commands.map((command) => command.snapshot.mergeability),
+ ["mergeable"],
+ );
+ }).pipe(Effect.provide(fixture.layer));
+ }),
+ ),
+ );
+
+ it.effect.each([
+ { provider: "github" as const, updatedAt: "2026-08-28T00:00:00.000Z" },
+ { provider: "forgejo" as const, updatedAt: "2026-08-27T00:00:00.000Z" },
+ ])("persists unknown mergeability for $provider at $updatedAt", ({ provider, updatedAt }) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(Date.parse(NOW));
+ const fixture = yield* makeHarness({
+ snapshot: makeSnapshot([
+ makeThread("one", {
+ pullRequests: [makeLink(42, { mergeability: "mergeable" })],
+ }),
+ ]),
+ summary: (input) =>
+ Effect.succeed(
+ makeSummary(input, {
+ provider: SourceControlProviderKind.make(provider),
+ updatedAt,
+ mergeability: "unknown",
+ }),
+ ),
+ });
+ yield* Effect.gen(function* () {
+ yield* startAndSweep(fixture);
+ const commands = yield* Ref.get(fixture.syncCommands);
+ assert.deepStrictEqual(
+ commands.map((command) => command.snapshot.mergeability),
+ ["unknown"],
+ );
+ }).pipe(Effect.provide(fixture.layer));
+ }),
+ ),
+ );
+
it.effect("refreshes closed links through each project after reopening elsewhere", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
index 2c97b1867ef48..2b3605e3a966e 100644
--- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
+++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
@@ -214,6 +214,23 @@ function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
};
}
+// GitHub briefly returns unknown after base pushes; other hosts may mean a lasting condition.
+function withKnownMergeability(
+ previous: ThreadPullRequestSnapshot | null,
+ fields: SnapshotFields,
+ provider: PullRequestSummary["provider"],
+): SnapshotFields {
+ if (
+ provider !== "github" ||
+ fields.state !== "open" ||
+ fields.mergeability !== "unknown" ||
+ previous?.updatedAt !== fields.updatedAt
+ )
+ return fields;
+ const known = previous?.state === "open" ? previous.mergeability : undefined;
+ return known === undefined || known === "unknown" ? fields : { ...fields, mergeability: known };
+}
+
// Rotating ghe.com avatar tokens should not make otherwise identical snapshots differ.
function avatarIdentity(avatarUrl: string | null | undefined): string | null {
if (avatarUrl === null || avatarUrl === undefined) return null;
@@ -396,10 +413,12 @@ export const make = Effect.gen(function* () {
// cache lookup forked inside keeps those frames, so a plain span leaves the sweep collectable.
const syncEntry = Effect.fnUntraced(function* (
entry: LinkEntry,
- fields: SnapshotFields,
+ hostFields: SnapshotFields,
fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null,
+ provider: PullRequestSummary["provider"],
) {
const { thread, link } = entry;
+ const fields = withKnownMergeability(link.snapshot, hostFields, provider);
const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack;
const changed =
link.snapshot === null ||
@@ -475,7 +494,10 @@ export const make = Effect.gen(function* () {
entries.some(
(entry) =>
entry.link.snapshot === null ||
- !snapshotFieldsEqual(entry.link.snapshot, fields) ||
+ !snapshotFieldsEqual(
+ entry.link.snapshot,
+ withKnownMergeability(entry.link.snapshot, fields, summary.provider),
+ ) ||
(summary.stack !== undefined &&
(entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)),
);
@@ -542,7 +564,7 @@ export const make = Effect.gen(function* () {
yield* Effect.forEach(
entries,
(entry) =>
- syncEntry(entry, fields, fetchedStack).pipe(
+ syncEntry(entry, fields, fetchedStack, summary.provider).pipe(
persistence.withPermits(1),
Effect.catchCause((cause) => {
if (!Cause.hasInterruptsOnly(cause)) retryStacks.add(key);
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
index 13ee4f3939a13..642bc656c21cb 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
@@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
+import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts";
import * as RunExecutionService from "./RunExecutionService.ts";
import * as RunFinalizationService from "./RunFinalizationService.ts";
@@ -1407,10 +1408,21 @@ it.effect("ingests new member threads of an owned child after the root turn term
const subagentNodeId = NodeId.make("node:run-execution-late-child:subagent");
const childMessageIngested = yield* Deferred.make();
const order = yield* Ref.make>([]);
- const origins = yield* Ref.make>([]);
+ const origins = yield* Ref.make>([]);
+ const requests = yield* Ref.make>([]);
const layerTest = RunExecutionService.layer.pipe(
Layer.provide(
Layer.mergeAll(
+ Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, {
+ runStarted: (input) =>
+ Ref.update(requests, (current) => [...current, `started:${input.runId}`]),
+ turnCompleted: () => Effect.void,
+ runSettled: (input) =>
+ Ref.update(requests, (current) => [
+ ...current,
+ `settled:${input.runId}:${input.status}:children-running=${input.childrenRunning}`,
+ ]),
+ }),
McpAppModelContext.layerEmpty,
Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
layerTurnItemPositionsTest,
@@ -1436,7 +1448,11 @@ it.effect("ingests new member threads of an owned child after the root turn term
const eventType = input.event.type;
yield* Ref.update(origins, (current) => [
...current,
- [eventType, input.analyticsContext?.origin] as const,
+ [
+ eventType,
+ input.analyticsContext?.origin,
+ input.analyticsContext?.segment,
+ ] as const,
]);
if (
input.event.type === "app_thread.created" &&
@@ -1596,6 +1612,7 @@ it.effect("ingests new member threads of an owned child after the root turn term
attempt: {
id: attemptId,
providerTurnId: rootProviderTurnId,
+ reason: "initial",
} as OrchestrationV2RunAttempt,
attemptId,
providerTurnOrdinal: 1,
@@ -1628,7 +1645,7 @@ it.effect("ingests new member threads of an owned child after the root turn term
assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]);
// The run's message started the root; the parent agent started the child's turn.
const recorded = yield* Ref.get(origins);
- assert.deepEqual(recorded[0], [
+ assert.deepEqual(recorded[0]?.slice(0, 2), [
"app_thread.created",
{ createdBy: "user", creationSource: "web", scheduled: false, subagent: false },
]);
@@ -1637,8 +1654,14 @@ it.effect("ingests new member threads of an owned child after the root turn term
[
"provider_turn.updated",
{ createdBy: "agent", creationSource: "provider", scheduled: false, subagent: true },
+ "native_child",
],
);
+ // The child outlives the root, so the request summary says its later turns are missing.
+ assert.deepEqual(yield* Ref.get(requests), [
+ `started:${runId}`,
+ `settled:${runId}:completed:children-running=true`,
+ ]);
}),
);
@@ -2513,22 +2536,43 @@ it.effect("does not overwrite Stop when ownership changes after the finalization
}),
);
-it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () =>
- Effect.gen(function* () {
- const { written, observed, committedEffects } = yield* captureRootRunTermination({
- key: "hard-stop",
- shouldFinalizeRun: () => Effect.succeed(true),
- });
- assert.deepEqual(
- written.map((item) => item.type),
- ["run_interrupt_result"],
- );
- assert.deepEqual(observed, ["run:interrupted", "pull-requests-refreshed"]);
- assert.deepEqual(
- committedEffects.map((effect) => effect.request.type),
- ["checkpoint.capture"],
- );
- }),
+it.effect.each([
+ { providerReason: undefined, requestReason: undefined, expected: "Stopped by the provider" },
+ {
+ providerReason: "preCompact hook (project): Save a checkpoint.",
+ requestReason: undefined,
+ expected: "preCompact hook (project): Save a checkpoint.",
+ },
+ {
+ providerReason: "preCompact hook (project): Save a checkpoint.",
+ requestReason: "Stopped by the user",
+ expected: "Stopped by the user",
+ },
+])(
+ "emits run_interrupt_result with $expected when finalizing the active attempt",
+ ({ providerReason, requestReason, expected }) =>
+ Effect.gen(function* () {
+ const { written, observed, committedEffects } = yield* captureRootRunTermination({
+ key: "hard-stop",
+ shouldFinalizeRun: () => Effect.succeed(true),
+ loadRunInterruptReason: () => Effect.succeed(requestReason),
+ events: (ids) =>
+ Stream.make({ ...rootTerminalEvent(ids, "interrupted"), reason: providerReason }),
+ });
+ assert.deepEqual(
+ written.map((item) => item.type),
+ ["run_interrupt_result"],
+ );
+ assert.deepEqual(observed, ["run:interrupted", "pull-requests-refreshed"]);
+ assert.equal(
+ written[0]?.type === "run_interrupt_result" ? written[0].message : undefined,
+ expected,
+ );
+ assert.deepEqual(
+ committedEffects.map((effect) => effect.request.type),
+ ["checkpoint.capture"],
+ );
+ }),
);
it.effect("records a finished run as failed when its ownership check cannot be read", () =>
@@ -2917,6 +2961,8 @@ function captureRootRunTermination(input: {
readonly shouldFinalizeRun: () => Effect.Effect;
readonly rejectTerminalWrite?: boolean;
readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect;
+ readonly loadRunInterruptReason?: () => Effect.Effect;
+ readonly hasPendingBackgroundWorkForThread?: ProviderAdapter.ProviderAdapterV2SessionRuntime["hasPendingBackgroundWorkForThread"];
readonly seedOpenSubagent?: boolean;
readonly events?: (
ids: BackgroundScenarioIds,
@@ -3096,6 +3142,9 @@ function captureRootRunTermination(input: {
attemptId: ids.attemptId,
providerTurnOrdinal: 1,
shouldFinalizeRun: input.shouldFinalizeRun,
+ ...(input.loadRunInterruptReason === undefined
+ ? {}
+ : { loadRunInterruptReason: input.loadRunInterruptReason }),
...(input.shouldStartProviderTurn === undefined
? {}
: { shouldStartProviderTurn: input.shouldStartProviderTurn }),
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts
index 4566cf4c97db8..4464d7b3050ae 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.ts
@@ -45,6 +45,7 @@ import * as EventSink from "./EventSink.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
+import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import type { ProjectionStoreV2Error } from "./ProjectionStore.ts";
import {
knownProviderFailureMessage,
@@ -97,7 +98,7 @@ function isSettledSubagentStatus(status: OrchestrationV2Subagent["status"]): boo
}
/** A provider-native subagent's turns arrive on its parent's run, but the parent agent started them. */
-const NATIVE_SUBAGENT_TURN_ORIGIN: ProviderEventIngestor.ProviderTurnOrigin = {
+const NATIVE_SUBAGENT_TURN_ORIGIN: ProviderUsageAnalytics.ProviderTurnOrigin = {
createdBy: "agent",
creationSource: "provider",
scheduled: false,
@@ -612,6 +613,7 @@ export const layer: Layer.Layer<
const mcpAppModelContext = yield* McpAppModelContext.McpAppModelContext;
const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2;
const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver;
+ const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics;
const writeFinalRunEvents = Effect.fn("RunExecutionService.writeFinalRunEvents")(
function* (input: {
@@ -683,8 +685,10 @@ export const layer: Layer.Layer<
}
const allocateEventId = () => idAllocator.allocate.event({ threadId: input.run.threadId });
const reason =
- input.terminal.status === "interrupted" && input.loadRunInterruptReason !== undefined
- ? yield* input.loadRunInterruptReason()
+ input.terminal.status === "interrupted"
+ ? ((input.loadRunInterruptReason === undefined
+ ? undefined
+ : yield* input.loadRunInterruptReason()) ?? input.terminal.reason)
: undefined;
const open = input.openRunOwnedSubagents ?? emptyOpenRunOwnedSubagentProjection();
const hasOpenSubagentProjection =
@@ -866,13 +870,19 @@ export const layer: Layer.Layer<
} else {
yield* eventSink.writeWithEffects(finalization);
}
+ yield* turnAnalytics.runSettled({
+ runId: input.run.id,
+ status: input.terminal.status,
+ completedAt,
+ childrenRunning: hasOpenSubagentProjection && cascadedSubagentEvents.length === 0,
+ });
yield* input.refreshAfterTurn;
},
);
return RunExecutionServiceV2.of({
startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) {
- const rootTurnOrigin: ProviderEventIngestor.ProviderTurnOrigin = {
+ const rootTurnOrigin: ProviderUsageAnalytics.ProviderTurnOrigin = {
createdBy: input.message.createdBy,
creationSource: input.message.creationSource,
scheduled: input.message.scheduledTaskId !== undefined,
@@ -992,6 +1002,25 @@ export const layer: Layer.Layer<
const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100);
const latestProviderThread = yield* Ref.make(input.providerThread);
const latestRootProviderTurn = yield* Ref.make(null);
+ // Later root turns in one attempt are provider continuations, such as Codex goal turns.
+ let firstRootProviderTurnId: ProviderTurnId | undefined;
+ const analyticsContext = {
+ runId: input.run.id,
+ modelSelection: input.modelSelection,
+ runtimeMode: input.runtimePolicy.runtimeMode,
+ interactionMode: input.runtimePolicy.interactionMode,
+ origin: rootTurnOrigin,
+ ...(input.run.restartContinuationOfRunId === undefined
+ ? {}
+ : { restartContinuation: true }),
+ } satisfies ProviderUsageAnalytics.ProviderTurnAnalyticsContext;
+ if (input.attempt.reason === "initial") {
+ yield* turnAnalytics.runStarted({
+ runId: input.run.id,
+ driver: input.providerThread.driver,
+ context: { ...analyticsContext, segment: "initial" },
+ });
+ }
const routeIdentity: ProviderEventRouteIdentity = {
threadId: input.run.threadId,
runId: input.run.id,
@@ -1389,17 +1418,27 @@ export const layer: Layer.Layer<
const isRootProviderThreadUpdate =
event.type === "provider_thread.updated" &&
event.providerThread.id === input.providerThread.id;
+ const nativeChildTurn =
+ event.type === "provider_turn.updated" &&
+ event.providerTurn.providerThreadId !== input.providerThread.id;
+ if (event.type === "provider_turn.updated" && !nativeChildTurn) {
+ firstRootProviderTurnId ??= event.providerTurn.id;
+ }
const storedEvents = yield* providerEventIngestor.ingestNormalized({
- analyticsContext: {
- modelSelection: input.modelSelection,
- runtimeMode: input.runtimePolicy.runtimeMode,
- interactionMode: input.runtimePolicy.interactionMode,
- origin:
- event.type === "provider_turn.updated" &&
- event.providerTurn.providerThreadId !== input.providerThread.id
- ? NATIVE_SUBAGENT_TURN_ORIGIN
- : rootTurnOrigin,
- },
+ analyticsContext: nativeChildTurn
+ ? {
+ ...analyticsContext,
+ origin: NATIVE_SUBAGENT_TURN_ORIGIN,
+ segment: "native_child",
+ }
+ : {
+ ...analyticsContext,
+ segment:
+ event.type === "provider_turn.updated" &&
+ event.providerTurn.id !== firstRootProviderTurnId
+ ? "continuation"
+ : input.attempt.reason,
+ },
providerSessionId: input.providerSessionId,
providerInstanceId: input.run.providerInstanceId,
threadId: input.run.threadId,
@@ -1763,7 +1802,6 @@ export function makeInterruptResultTurnItem(input: {
readonly run: OrchestrationV2Run;
readonly rootNode: OrchestrationV2ExecutionNode;
readonly providerThread: OrchestrationV2ProviderThread;
- /** The Stop's own reason, from its run_interrupt_request item. */
readonly reason: string | undefined;
readonly completedAt: DateTime.Utc;
}): OrchestrationV2TurnItem {
diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
index d059c4c20e9d7..4352d8b2d6477 100644
--- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
+++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
@@ -5,6 +5,7 @@ import {
MessageId,
type ModelSelection,
type OrchestrationV2ProviderCapabilities,
+ type OrchestrationV2ThreadProjection,
type OrchestrationV2ProviderSession,
type OrchestrationV2ProviderThread,
ProjectId,
@@ -47,6 +48,10 @@ const seedSelection = {
instanceId: providerInstanceId,
model: "seed-model",
} satisfies ModelSelection;
+const modelSwitches = (projection: OrchestrationV2ThreadProjection) =>
+ projection.turnItems.flatMap((item) =>
+ item.type === "user_message" && item.modelSwitch !== undefined ? [item.modelSwitch] : [],
+ );
const pooledCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2;
const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = {
...CodexProviderCapabilitiesV2,
@@ -407,6 +412,9 @@ it.live("retries a failed selection restart session open before completing", ()
"selection restart supersede must not project hard-Stop interrupt items",
);
assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model);
+ assert.deepEqual(modelSwitches(projection), [
+ { from: initialSelection, to: replacementSelection },
+ ]);
assert.equal(projection.runs.at(-1)?.status, "completed");
assert.isTrue(captured.failedReplacementOpen);
assert.equal(captured.closedSessionCount, 0);
@@ -690,6 +698,9 @@ it.live(
const { projection, captured } = result;
assert.lengthOf(projection.runs, 2);
assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model);
+ assert.deepEqual(modelSwitches(projection), [
+ { from: seedSelection, to: replacementSelection },
+ ]);
assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]);
assert.equal(captured.closedSessionCount, 1);
assert.deepEqual(
diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts
index c09b7401df773..840a24a3da18a 100644
--- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts
+++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts
@@ -448,6 +448,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function*
name: string,
canRestart = false,
canSteer = true,
+ interruptTurn: Effect.Effect = Effect.void,
) {
const cwd = yield* checkpointWorkspace(name);
const events = yield* Queue.unbounded();
@@ -538,7 +539,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function*
interruptTurn: ({ providerTurnId }) =>
Effect.sync(() => {
interrupted.push(providerTurnId);
- }),
+ }).pipe(Effect.andThen(interruptTurn)),
respondToRuntimeRequest: () => Effect.void,
readThreadSnapshot: () => Effect.die("unused"),
rollbackThread: () => Effect.die("unused"),
@@ -1174,6 +1175,55 @@ it.effect("steers a promoted queued message with the selection it was queued wit
),
);
+it.effect("marks the model switch on a promoted message that restarts the turn", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const { started, layer, startFirstTurn } = yield* nextTurnSelectionHarness(
+ "promote-restart-switch",
+ true,
+ false,
+ );
+ const otherSelection = { ...runSelection, model: "other-model" };
+ yield* Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
+ const threadId = yield* startFirstTurn;
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make("queued"),
+ threadId,
+ messageId: MessageId.make("message:queued"),
+ text: "queued",
+ attachments: [],
+ modelSelection: otherSelection,
+ dispatchMode: { type: "queue_after_active" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
+ (run) => run.status === "queued",
+ )!;
+ yield* orchestrator.dispatch({
+ type: "queued-message.promote-to-steer",
+ commandId: CommandId.make("promote"),
+ threadId,
+ queuedRunId: queuedRun.id,
+ targetRunId: started[0]!.runId,
+ });
+ yield* worker.drain();
+
+ const promoted = (yield* orchestrator.getThreadProjection(threadId)).turnItems.find(
+ (item) => item.type === "user_message" && item.messageId === "message:queued",
+ );
+ assert.deepEqual(promoted?.type === "user_message" ? promoted.modelSwitch : undefined, {
+ from: runSelection,
+ to: otherSelection,
+ });
+ }).pipe(Effect.provide(layer));
+ }),
+ ),
+);
+
it.effect("keeps a queued message queued when promoted into a run that Stop reached", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -1233,8 +1283,16 @@ it.effect("keeps a queued message queued when promoted into a run that Stop reac
it.effect("queues a plain send behind the stopped queue and releases its hold", () =>
Effect.scoped(
Effect.gen(function* () {
- const { started, steered, layer, startFirstTurn } =
- yield* nextTurnSelectionHarness("plain-send-after-stop");
+ const interruptReached = yield* Deferred.make();
+ const releaseInterrupt = yield* Deferred.make();
+ const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness(
+ "plain-send-after-stop",
+ false,
+ true,
+ Deferred.succeed(interruptReached, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseInterrupt)),
+ ),
+ );
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
@@ -1264,10 +1322,12 @@ it.effect("queues a plain send behind the stopped queue and releases its hold",
});
// The provider has not stopped yet, so a plain send still resolves to a steer.
yield* send("sent", "auto");
- yield* worker.drain();
+ // Once the stop settles, the run-update reactor promotes the queue on
+ // its own fiber, so read the queue while the provider is still stopping.
+ const draining = yield* worker.drain().pipe(Effect.forkScoped);
+ yield* Deferred.await(interruptReached);
const projection = yield* orchestrator.getThreadProjection(threadId);
- assert.deepEqual(steered, []);
assert.equal(started.length, 1);
const queued = projection.runs
.filter((run) => run.status === "queued")
@@ -1280,6 +1340,9 @@ it.effect("queues a plain send behind the stopped queue and releases its hold",
);
// The user sent it, so the queue they stopped runs again once the provider stops.
assert.isFalse(queued.some((run) => run.queueHeld === true));
+ yield* Deferred.succeed(releaseInterrupt, undefined);
+ yield* Fiber.join(draining);
+ assert.deepEqual(steered, []);
}).pipe(Effect.provide(layer));
}),
),
diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts
index ed643c2ff1ab7..ae8f7e09d2068 100644
--- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts
+++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts
@@ -274,15 +274,25 @@ it("waits for nested work and retains the report across monitor acknowledgements
"result_available",
);
}
- assert.equal(
- delegatedTaskProgress({
- ...projection,
- providerThreads: [
- { pendingBackgroundTasks: [{ taskId: "background-audit", kind: "command" }] },
- ],
- }).state,
- "waiting_for_children",
- );
+ for (const [awaited, state] of [
+ [true, "waiting_for_children"],
+ [false, "result_available"],
+ ] as const) {
+ assert.equal(
+ delegatedTaskProgress({
+ ...projection,
+ providerThreads: [
+ {
+ pendingBackgroundTasks: [
+ { taskId: "background-audit", kind: "command", ...(awaited ? { awaited } : {}) },
+ { taskId: "log-watcher", kind: "monitor" },
+ ],
+ },
+ ],
+ }).state,
+ state,
+ );
+ }
const pending = {
...run,
id: RunId.make("followup"),
diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts
index 6d401d968491d..5b35b5d195e86 100644
--- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts
@@ -134,14 +134,42 @@ const planFork = (
});
}).pipe(Effect.provide(ThreadForkService.layer));
-it.effect("keeps a fork awake when its source thread is snoozed", () =>
+it.effect("resets a fork's pin, snooze, settle and delegation state but keeps its origin", () =>
Effect.gen(function* () {
- const sourceThread = makeSourceThread();
+ const linkOrigin = { sessionId: "mcp-session:fork-source", label: "Linked laptop" };
+ const sourceThread: OrchestrationV2AppThread = {
+ ...makeSourceThread(),
+ lastSnoozeWakeAt: snoozedAt,
+ pinnedAt: snoozedAt,
+ pinOrderKey: "a0",
+ activeOrderKey: "a1",
+ autoSettleDisabledAt: snoozedAt,
+ delegatedFrom: {
+ environmentId: EnvironmentId.make("linked-environment"),
+ threadId: ThreadId.make("linked-parent"),
+ title: "Linked parent",
+ },
+ historyOrigin: "v1_import",
+ linkOrigin,
+ tags: ["private"],
+ };
const sourceRun = makeSourceRun("completed");
- const result = yield* planFork(sourceRun);
+ const result = yield* planFork(sourceRun, {
+ ...makeSourceProjection(sourceRun),
+ thread: sourceThread,
+ });
assert.isNull(result.targetThread.snoozedUntil);
assert.isNull(result.targetThread.snoozedAt);
+ assert.isNull(result.targetThread.lastSnoozeWakeAt);
+ assert.isNull(result.targetThread.pinnedAt);
+ assert.isNull(result.targetThread.pinOrderKey);
+ assert.isNull(result.targetThread.activeOrderKey);
+ assert.isNull(result.targetThread.autoSettleDisabledAt);
+ assert.isUndefined(result.targetThread.delegatedFrom);
+ assert.equal(result.targetThread.historyOrigin, "v1_import");
+ assert.deepEqual(result.targetThread.linkOrigin, linkOrigin);
+ assert.deepEqual(result.targetThread.tags, ["private"]);
assert.equal(result.targetThread.projectId, sourceThread.projectId);
assert.equal(result.targetThread.providerInstanceId, sourceThread.providerInstanceId);
assert.deepEqual(result.targetThread.modelSelection, sourceThread.modelSelection);
diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts
index 4717daf75e30c..6790a2917ae6f 100644
--- a/apps/server/src/orchestration-v2/ThreadForkService.ts
+++ b/apps/server/src/orchestration-v2/ThreadForkService.ts
@@ -99,8 +99,10 @@ export const layer: Layer.Layer = Layer.succeed(
cause: forkableSourceRunStatusError(sourceRun),
});
}
+ // The remote parent delegated the source thread, not its forks.
+ const { delegatedFrom: _delegatedFrom, ...inherited } = input.sourceProjection.thread;
const targetThread: OrchestrationV2AppThread = {
- ...input.sourceProjection.thread,
+ ...inherited,
// PR context follows the conversation; watching remains the source thread's responsibility.
pullRequests: input.sourceProjection.thread.pullRequests?.map(
({ watch: _watch, ...link }) => link,
@@ -131,6 +133,11 @@ export const layer: Layer.Layer = Layer.succeed(
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
+ lastSnoozeWakeAt: null,
+ pinnedAt: null,
+ pinOrderKey: null,
+ activeOrderKey: null,
+ autoSettleDisabledAt: null,
// A guest link belongs to the thread it was made for.
guestShare: null,
lastVisitedAt: null,
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
index 79a6761464394..42f2ac0e0a00d 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
@@ -1117,6 +1117,29 @@ it.effect.each(["stable", "upstream/release"])(
),
);
+it.effect("fetches the origin base from the remote it already resolved", () => {
+ let fetchInput: Deferred.Deferred | null = null;
+ const harness = makeHarness({
+ fetchWorktreeBaseCommit: (input) => Deferred.succeed(fetchInput!, input).pipe(Effect.as(null)),
+ });
+ return Effect.gen(function* () {
+ fetchInput = yield* Deferred.make();
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ yield* launches.launch(
+ launchInput({
+ command: "command:launch:resolved-remote",
+ thread: "thread:launch:resolved-remote",
+ workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
+ }),
+ );
+ assert.deepEqual(yield* Deferred.await(fetchInput), {
+ cwd: project.workspaceRoot,
+ refName: "main",
+ remote: { remoteName: "origin", branchName: "main" },
+ });
+ }).pipe(Effect.provide(harness.layer));
+});
+
it.effect("shows the fetch diagnosis when preparing a worktree from origin fails", () => {
const detail =
"Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.";
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
index b68eef4b2a40c..4ab38c4b305e3 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
@@ -396,20 +396,23 @@ const make = Effect.gen(function* () {
}
let startRef = input.workspaceStrategy.baseRef;
let baseRefName = input.workspaceStrategy.baseRef;
- const shouldFetch =
- input.workspaceStrategy.startFromOrigin === true &&
- (yield* git
- .resolveWorktreeBaseRemote({
- cwd: project.workspaceRoot,
- refName: input.workspaceStrategy.baseRef,
- })
- .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))) !== null;
+ const remote =
+ input.workspaceStrategy.startFromOrigin === true
+ ? yield* git
+ .resolveWorktreeBaseRemote({
+ cwd: project.workspaceRoot,
+ refName: input.workspaceStrategy.baseRef,
+ })
+ .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))
+ : null;
+ const shouldFetch = remote !== null;
yield* setupTracker.stageStatus(threadId, "fetch", shouldFetch ? "running" : "skipped");
- if (shouldFetch) {
+ if (remote !== null) {
const remoteBase = yield* git
.fetchWorktreeBaseCommit({
cwd: project.workspaceRoot,
refName: input.workspaceStrategy.baseRef,
+ remote,
})
.pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
if (remoteBase !== null) {
diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts
index 16dcedaf8ac9c..6b3ea08a86449 100644
--- a/apps/server/src/orchestration-v2/ThreadStop.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts
@@ -1664,3 +1664,81 @@ it.effect("an unanswered Stop is flagged until Force stop ends the run with its
assert.lengthOf(late.storedEvents, 0);
}).pipe(Effect.provide(layerTest)),
);
+
+it.effect.each(["Stop", "Force stop"] as const)(
+ "%s ends a running run whose provider turn already completed",
+ (action) =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const threadId = ThreadId.make("thread:stop-completed-turn");
+ yield* createWatchingThread(threadId, 7);
+ yield* send(threadId, "go", "start_immediately");
+ const now = yield* DateTime.now;
+ const started = yield* orchestrator.getThreadProjection(threadId);
+ const run = started.runs[0]!;
+ const attempt = started.attempts[0]!;
+ const providerThreadId = run.providerThreadId!;
+ const providerTurnId = ProviderTurnId.make("provider-turn:stop-completed-turn");
+ const apply = (event: Omit) =>
+ projections.apply({
+ ...event,
+ id: EventId.make(`event:stop-completed-turn:${event.type}`),
+ threadId,
+ } as OrchestrationV2DomainEvent);
+ yield* apply({
+ type: "run.updated",
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "running", startedAt: now, activeAttemptId: attempt.id },
+ });
+ yield* apply({
+ type: "run-attempt.updated",
+ occurredAt: now,
+ payload: { ...attempt, status: "completed", startedAt: now, completedAt: now },
+ });
+ yield* apply({
+ type: "provider-turn.updated",
+ occurredAt: now,
+ payload: {
+ id: providerTurnId,
+ providerThreadId,
+ nodeId: run.rootNodeId!,
+ runAttemptId: attempt.id,
+ nativeTurnRef: null,
+ ordinal: 1,
+ status: "completed",
+ startedAt: now,
+ completedAt: now,
+ },
+ });
+ // The provider session was already released.
+ yield* apply({
+ type: "provider-thread.updated",
+ occurredAt: now,
+ payload: {
+ ...started.providerThreads.find((thread) => thread.id === providerThreadId)!,
+ providerSessionId: ProviderSessionId.make("session:gone"),
+ },
+ });
+ yield* send(threadId, "follow", "queue_after_active");
+
+ if (action === "Stop") {
+ yield* orchestrator.dispatch({
+ type: "thread.stop",
+ commandId: CommandId.make("stop-completed-turn"),
+ threadId,
+ });
+ } else {
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ yield* threads.forceStopThread({ threadId });
+ }
+ const stopped = yield* orchestrator.getThreadProjection(threadId);
+ assert.notEqual(stopped.runs[0]?.status, "running", "the stopped run is still running");
+ assert.deepInclude(stopped.attempts[0], { status: "completed", completedAt: now });
+ assert.isTrue(
+ stopped.turnItems.some((item) => item.type === "run_interrupt_result"),
+ "the stopped run has no interrupt result",
+ );
+ }).pipe(Effect.provide(layerTest)),
+);
diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts
index 80ee5c32d6152..c16a1b9e095aa 100644
--- a/apps/server/src/orchestration-v2/ThreadStream.ts
+++ b/apps/server/src/orchestration-v2/ThreadStream.ts
@@ -26,11 +26,15 @@ export function isThreadReplayRawPayloadSafe(rawPayloadBytes: number): boolean {
return rawPayloadBytes <= THREAD_RESUME_MAX_RAW_PAYLOAD_BYTES;
}
-export function threadReplayEncodedBytes(items: ReadonlyArray): number {
+export function threadReplayEncodedBytes(
+ items: ReadonlyArray,
+ limit: number = THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES,
+): number {
let total = 0;
for (const item of items) {
const encoded = JSON.stringify(item);
total += Buffer.byteLength(encoded ?? "", "utf8");
+ if (total > limit) break;
}
return total;
}
diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts
index 66857c791f2fd..2813c8f633ea2 100644
--- a/apps/server/src/orchestration-v2/WireProjection.test.ts
+++ b/apps/server/src/orchestration-v2/WireProjection.test.ts
@@ -175,7 +175,9 @@ describe("orchestration V2 wire projection", () => {
});
it("truncates detail at a UTF-8 boundary without changing the source", () => {
- const progress = "a".repeat(32_767) + "😀" + "x".repeat(100_000);
+ const marker = "\n… output truncated for transport";
+ const prefixBytes = 32_768 - Buffer.byteLength(marker);
+ const progress = "a".repeat(prefixBytes - 1) + "😀" + "x".repeat(100_000);
const item = {
...base,
type: "subagent" as const,
@@ -184,13 +186,17 @@ describe("orchestration V2 wire projection", () => {
driver: ProviderDriverKind.make("codex"),
providerInstanceId: ProviderInstanceId.make("codex"),
childThreadId: null,
- prompt: "Inspect code",
+ prompt: "漢".repeat(12_000),
result: null,
progress,
};
const projected = projectTurnItemForWire(item);
+ expect(projectTurnItemForWire(projected)).toEqual(projected);
+ expect(
+ Buffer.byteLength(projected.type === "subagent" ? projected.prompt : ""),
+ ).toBeLessThanOrEqual(32_768);
expect(projected.type === "subagent" ? projected.progress : null).toBe(
- "a".repeat(32_767) + "\n… output truncated for transport",
+ "a".repeat(prefixBytes - 1) + marker,
);
expect(item.progress).toBe(progress);
});
diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts
index 8f9292b29b9ff..16bfbbfd1152a 100644
--- a/apps/server/src/orchestration-v2/WireProjection.ts
+++ b/apps/server/src/orchestration-v2/WireProjection.ts
@@ -27,6 +27,8 @@ const MAX_WIRE_CHECKPOINT_FILES = 200;
const MAX_DETAIL_STRING_BYTES = 32_768;
const MAX_DYNAMIC_VALUE_BYTES = 16_384;
const MAX_ON_DEMAND_BYTES = 256 * 1024;
+const TRUNCATION_MARKER = "\n… output truncated for transport";
+const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, "utf8");
function truncateDetail(
value: string | undefined,
@@ -38,12 +40,14 @@ function truncateDetail(
) {
return value;
}
+ // The marker counts toward the cap, so projecting truncated text again leaves it unchanged.
+ const budget = maxBytes - TRUNCATION_MARKER_BYTES;
// UTF-8 needs at least one byte per UTF-16 unit; encode only a potentially fitting prefix.
- const prefix = Buffer.from(value.slice(0, maxBytes), "utf8")
- .subarray(0, maxBytes)
+ const prefix = Buffer.from(value.slice(0, budget), "utf8")
+ .subarray(0, budget)
.toString("utf8")
.replace(/\uFFFD$/u, "");
- return `${prefix}\n… output truncated for transport`;
+ return `${prefix}${TRUNCATION_MARKER}`;
}
/** Bound stored diffs to the client wire limit to avoid retaining invisible data in SQLite and heap. */
diff --git a/apps/server/src/orchestration-v2/modelSwitch.test.ts b/apps/server/src/orchestration-v2/modelSwitch.test.ts
new file mode 100644
index 0000000000000..940340074c1f2
--- /dev/null
+++ b/apps/server/src/orchestration-v2/modelSwitch.test.ts
@@ -0,0 +1,107 @@
+import {
+ ContextHandoffId,
+ MessageId,
+ ProviderInstanceId,
+ type OrchestrationV2ThreadProjection,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { modelSwitchSince } from "./modelSwitch.ts";
+
+const instanceId = ProviderInstanceId.make("codex");
+const selection = (model: string) => ({ instanceId, model });
+const run = (
+ message: string,
+ ordinal: number,
+ model: string,
+ status: OrchestrationV2ThreadProjection["runs"][number]["status"] = "completed",
+) =>
+ ({
+ ordinal,
+ status,
+ completedAt: null,
+ contextHandoffId: null,
+ userMessageId: MessageId.make(message),
+ modelSelection: selection(model),
+ }) as OrchestrationV2ThreadProjection["runs"][number];
+const shown = (...messages: ReadonlyArray) =>
+ messages.map((message) => ({
+ type: "user_message",
+ messageId: MessageId.make(message),
+ inputIntent: "turn_start",
+ status: "completed",
+ text: message,
+ attachments: [],
+ })) as unknown as OrchestrationV2ThreadProjection["turnItems"];
+
+describe("modelSwitchSince", () => {
+ it("measures from the latest run whose message is in the timeline", () => {
+ // B failed before it started; its message shows "a -> b", so the next one must show "b -> a".
+ expect(
+ modelSwitchSince(
+ {
+ runs: [
+ run("first", 1, "a"),
+ run("failed", 2, "b", "failed"),
+ run("queued", 3, "c", "queued"),
+ run("undone", 4, "d", "rolled_back"),
+ ],
+ turnItems: shown("first", "failed", "queued", "undone"),
+ },
+ selection("a"),
+ ),
+ ).toEqual({ modelSwitch: { from: selection("b"), to: selection("a") } });
+ });
+
+ it("skips messages clients hide", () => {
+ const [first, pending, compact, answer] = shown(
+ "first",
+ "pending",
+ "/compact",
+ "async-answer:question",
+ );
+ expect(
+ modelSwitchSince(
+ {
+ runs: [
+ run("first", 1, "a"),
+ run("pending", 2, "b", "cancelled"),
+ run("/compact", 3, "c"),
+ run("async-answer:question", 4, "d"),
+ ],
+ turnItems: [
+ first!,
+ { ...pending!, inputIntent: "queued_turn", status: "pending" },
+ compact!,
+ answer!,
+ ] as OrchestrationV2ThreadProjection["turnItems"],
+ },
+ selection("c"),
+ ),
+ ).toEqual({ modelSwitch: { from: selection("a"), to: selection("c") } });
+ });
+
+ it("counts a wake whose handoff row shows the swap", () => {
+ const wake = {
+ ...run("wake", 2, "other"),
+ contextHandoffId: ContextHandoffId.make("handoff"),
+ };
+ expect(
+ modelSwitchSince(
+ { runs: [run("first", 1, "a"), wake], turnItems: shown("first") },
+ selection("other"),
+ ),
+ ).toEqual({});
+ });
+
+ it("records nothing for a first run or an unchanged selection", () => {
+ expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"))).toEqual({});
+ // A fork's first message compares with the run it forked from.
+ expect(modelSwitchSince({ runs: [], turnItems: [] }, selection("a"), selection("b"))).toEqual({
+ modelSwitch: { from: selection("b"), to: selection("a") },
+ });
+ expect(
+ modelSwitchSince({ runs: [run("first", 1, "a")], turnItems: shown("first") }, selection("a")),
+ ).toEqual({});
+ });
+});
diff --git a/apps/server/src/orchestration-v2/modelSwitch.ts b/apps/server/src/orchestration-v2/modelSwitch.ts
new file mode 100644
index 0000000000000..5b893cd0026d2
--- /dev/null
+++ b/apps/server/src/orchestration-v2/modelSwitch.ts
@@ -0,0 +1,42 @@
+import {
+ isCompactionCommandMessage,
+ type ModelSelection,
+ type OrchestrationV2ThreadProjection,
+ type OrchestrationV2TurnItem,
+} from "@t3tools/contracts";
+import { modelSelectionsEqual } from "@t3tools/shared/model";
+import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
+
+/**
+ * The model or effort change a new run brings, measured from the latest run the timeline shows
+ * through its message or its handoff row, so consecutive dividers always chain. `baseline` stands
+ * in when there is none, such as a fork's source run. Callers skip starts with a context handoff;
+ * the handoff item already shows those.
+ */
+export function modelSwitchSince(
+ projection: Pick,
+ to: ModelSelection,
+ baseline?: ModelSelection,
+): Pick, "modelSwitch"> {
+ // Clients hide pending queued messages, fold async answers into their question, and replace
+ // `/compact` with a compaction row.
+ const shown = new Set(
+ projection.turnItems.flatMap((item) =>
+ item.type === "user_message" &&
+ !(item.inputIntent === "queued_turn" && item.status === "pending") &&
+ !item.messageId.startsWith("async-answer:") &&
+ !isCompactionCommandMessage({ role: "user", ...item })
+ ? [item.messageId]
+ : [],
+ ),
+ );
+ let previous: OrchestrationV2ThreadProjection["runs"][number] | undefined;
+ for (const run of projection.runs) {
+ if (run.status === "rolled_back" || run.status === "queued") continue;
+ // A wake shows as a notification, but its context handoff row still shows the swap.
+ if (!shown.has(run.userMessageId) && run.contextHandoffId === null) continue;
+ if (previous === undefined || runRanAfter(run, previous)) previous = run;
+ }
+ const from = previous?.modelSelection ?? baseline;
+ return from === undefined || modelSelectionsEqual(from, to) ? {} : { modelSwitch: { from, to } };
+}
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
index e68b73e4b7ba8..8d4c6195e6139 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
@@ -18,6 +18,7 @@ import {
TurnItemId,
type ModelSelection,
type OrchestrationV2Run,
+ type OrchestrationV2ShellSnapshot,
type OrchestrationV2RuntimeRequest,
ProjectId,
type PullRequestDetail,
@@ -2539,6 +2540,37 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
});
assert.equal((yield* orchestrator.getThreadProjection(threadId)).thread.activeOrderKey, "a0");
+ const shellOf = (shell: Pick) =>
+ shell.threads.find((thread) => thread.id === threadId)!;
+ const untaggedShell = shellOf(yield* orchestrator.getShellSnapshot());
+ yield* TestClock.adjust("1 minute");
+ for (const [step, tag, tagged, expected] of [
+ ["add", "demo", true, ["demo"]],
+ ["retry", "demo", true, ["demo"]],
+ ["add-other", "private", true, ["demo", "private"]],
+ ["remove", "demo", false, ["private"]],
+ ["remove-last", "private", false, []],
+ ] as const) {
+ yield* orchestrator.dispatch({
+ type: "thread.tag.set",
+ commandId: CommandId.make(`runtime-layer-lifecycle-tag-${step}`),
+ threadId,
+ tag,
+ tagged,
+ });
+ assert.deepEqual(
+ shellOf(yield* orchestrator.getShellSnapshot()).tags ?? [],
+ expected,
+ step,
+ );
+ }
+ const taggedShell = shellOf(yield* orchestrator.getShellSnapshot());
+ // Tags are not activity, so the thread keeps its place in thread lists.
+ assert.equal(
+ DateTime.toEpochMillis(taggedShell.updatedAt),
+ DateTime.toEpochMillis(untaggedShell.updatedAt),
+ );
+
const preAutoProjection = yield* orchestrator.getThreadProjection(threadId);
const staleAutoSettle = yield* orchestrator
.dispatch({
@@ -6915,7 +6947,7 @@ it.layer(layerScratchPromotionTest)("turning a No project thread into a project"
});
it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
- it.effect("carries snooze state through the V2 shell projection", () =>
+ it.effect("carries snooze, settle and title state through the V2 shell projection", () =>
Effect.gen(function* () {
const projects = yield* ProjectService.ProjectService;
const orchestrator = yield* Orchestrator.OrchestratorV2;
@@ -6984,6 +7016,11 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
);
const [candidate] = yield* projections.getSettlementCandidates(threadId);
assert.deepEqual(candidate?.lastSnoozeWakeAt, yield* DateTime.now);
+ yield* orchestrator.dispatch({
+ type: "thread.settle",
+ commandId: CommandId.make("runtime-layer-snoozed-thread-settle"),
+ threadId,
+ });
yield* orchestrator.dispatch({
type: "thread.snooze",
commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-wake"),
@@ -6999,6 +7036,7 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
threadId,
messageId: MessageId.make("runtime-layer-snoozed-message"),
text: "Wake this thread.",
+ titleSeed: "Woken thread",
attachments: [],
modelSelection,
dispatchMode: { type: "start_immediately" },
@@ -7007,6 +7045,13 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
const awakened = yield* orchestrator.getThreadProjection(threadId);
assert.isNull(awakened.thread.snoozedUntil);
assert.isNull(awakened.thread.snoozedAt);
+ assert.isNull(awakened.thread.settledOverride);
+ assert.isNull(awakened.thread.settledAt);
+ assert.equal(awakened.thread.title, "Woken thread");
+ assert.equal(
+ awakened.thread.titleRegeneration?.requestId,
+ CommandId.make("runtime-layer-snoozed-message"),
+ );
}),
);
diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
index 77e8bae8f085f..a3151462bbe00 100644
--- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
+++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
@@ -147,6 +147,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray
+ projection.providerTurns.find((turn) => turn.runAttemptId === run.activeAttemptId)
+ ?.turnTokenUsage,
+ );
+ assert.deepInclude(usage[0], {
+ outputTokens: 7,
+ otherUsage: { inputTokens: 1015, outputTokens: 12 },
+ });
+ // /compact reports zero tokens as a local command.
+ assert.deepInclude(usage[1], {
+ usageStatus: "complete",
+ inputTokens: 0,
+ outputTokens: 0,
+ localCommand: true,
+ });
+ // The peer result's ledger is empty, so neither it nor the next result has a baseline.
+ assert.deepInclude(usage[2], { usageStatus: "complete", outputTokens: 4 });
+ assert.notProperty(usage[1], "otherUsage");
+ assert.notProperty(usage[2], "otherUsage");
}
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/output.ts
index cf8fac0d3a1a2..c5c114d441d02 100644
--- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/output.ts
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_idle_resume/output.ts
@@ -32,4 +32,15 @@ export function assertClaudeIdleResumeOutput(
"idle resume first turn complete",
"idle resume second turn complete",
]);
+ // The resumed process's ledger starts at zero, so the first turn is not counted again.
+ assert.deepEqual(
+ projection.providerTurns.map((turn) => [
+ turn.turnTokenUsage?.outputTokens,
+ turn.turnTokenUsage?.otherUsage,
+ ]),
+ [
+ [7, { inputTokens: 0, outputTokens: 0 }],
+ [7, { inputTokens: 0, outputTokens: 0 }],
+ ],
+ );
}
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts
index 99b218f215f6c..6d4866078ed3a 100644
--- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts
@@ -56,4 +56,16 @@ export function assertClaudeNestedSubagentModelOutput(
const nestedChild = result.projections.get(nested.childThreadId);
assert.isDefined(nestedChild);
assert.equal(nestedChild.thread.modelSelection.model, SUBAGENT_MODEL);
+
+ // The result counts only the root; the process ledger adds both subagents' work once.
+ assert.lengthOf(projection.providerTurns, 1);
+ assert.deepInclude(projection.providerTurns[0]?.turnTokenUsage, {
+ usageStatus: "complete",
+ hasSubagents: true,
+ inputTokens: 74_167,
+ outputTokens: 236,
+ reasoningTokens: 30,
+ models: ["claude-sonnet-4-6"],
+ otherUsage: { inputTokens: 87_409, outputTokens: 335 },
+ });
}
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts
index 2dc757cdb86d7..a49a847fddc5e 100644
--- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts
@@ -41,6 +41,8 @@ export function assertClaudeResultIsErrorOutput(
(turn) => turn.status !== "completed" && turn.status !== "running",
);
assert.equal(failedTurn?.status, "failed");
+ // The API error's "" frame is a CLI notice, not a model that did work.
+ assert.notProperty(failedTurn?.turnTokenUsage, "models");
const errorItem = projection.turnItems.find(
(item) => item.runId === failedRun?.id && item.type === "error",
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson
new file mode 100644
index 0000000000000..6b90c579a26a3
--- /dev/null
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson
@@ -0,0 +1,47 @@
+{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"codex_model_reroute","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"multi_turn.ndjson","description":"The multi_turn recording with a model/rerouted notification added by hand to the first turn. The second turn gets no new thread settings.","model":"gpt-6-luna"}}
+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
+{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
+{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
+{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000},"cwd":"","model":"gpt-6-luna"}}}
+{"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295480414}}
+{"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295480414}}
+{"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}}
+{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}}
+{"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295480656}}
+{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295480656}}
+{"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295480666}}
+{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}}
+{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295480718}}
+{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295480,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295480718}}
+{"type":"emit_inbound","label":"model/rerouted","frame":{"method":"model/rerouted","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","fromModel":"gpt-6-luna","toModel":"gpt-6-astra","reason":"highRiskCyberActivity"}}}
+{"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295481015}}
+{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295481236}}
+{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295481609}}
+{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-22a8-7e91-9510-1578c7566bc0","clientId":null,"content":[{"type":"text","text":"Respond with exactly: first fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","startedAtMs":1790295483054},"emittedAtMs":1790295483054}}
+{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d5ed-22a8-7e91-9510-1578c7566bc0","clientId":null,"content":[{"type":"text","text":"Respond with exactly: first fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","completedAtMs":1790295483054},"emittedAtMs":1790295483061}}
+{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","startedAtMs":1790295485125},"emittedAtMs":1790295485125}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","itemId":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","delta":"first"},"emittedAtMs":1790295485125}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","itemId":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","delta":" fixture"},"emittedAtMs":1790295485125}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","itemId":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","delta":" turn"},"emittedAtMs":1790295485125}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","itemId":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","delta":" complete"},"emittedAtMs":1790295485125}}
+{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","completedAtMs":1790295485125},"emittedAtMs":1790295485134}}
+{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295485211}}
+{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"idle"}},"emittedAtMs":1790295485220}}
+{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295480,"completedAt":1790295485,"durationMs":4550}},"emittedAtMs":1790295485220}}
+{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}}
+{"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295485265}}
+{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}}
+{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295485286}}
+{"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295485,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295485286}}
+{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-2bdf-7093-9e82-b961af1fe132","clientId":null,"content":[{"type":"text","text":"Respond with exactly: second fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","startedAtMs":1790295485409},"emittedAtMs":1790295485409}}
+{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d5ed-2bdf-7093-9e82-b961af1fe132","clientId":null,"content":[{"type":"text","text":"Respond with exactly: second fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","completedAtMs":1790295485409},"emittedAtMs":1790295485413}}
+{"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","text":"","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","startedAtMs":1790295487332},"emittedAtMs":1790295487332}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","itemId":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","delta":"second"},"emittedAtMs":1790295487332}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","itemId":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","delta":" fixture"},"emittedAtMs":1790295487332}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","itemId":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","delta":" turn"},"emittedAtMs":1790295487332}}
+{"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","itemId":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","delta":" complete"},"emittedAtMs":1790295487332}}
+{"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","text":"second fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","completedAtMs":1790295487382},"emittedAtMs":1790295487398}}
+{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","tokenUsage":{"total":{"totalTokens":47211,"inputTokens":47195,"cachedInputTokens":28160,"cacheWriteInputTokens":0,"outputTokens":16,"reasoningOutputTokens":0},"last":{"totalTokens":25785,"inputTokens":25777,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295487407}}
+{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"idle"}},"emittedAtMs":1790295487412}}
+{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbf08a087d08c1dafcd3f15697a","text":"second fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295485,"completedAt":1790295487,"durationMs":2138}},"emittedAtMs":1790295487412}}
+{"type":"runtime_exit","status":"success"}
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/output.ts
new file mode 100644
index 0000000000000..cca633fd5d789
--- /dev/null
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/output.ts
@@ -0,0 +1,21 @@
+import { assert } from "@effect/vitest";
+import type { ProviderReplayTranscript } from "@t3tools/contracts";
+
+import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
+import { assertBaseProjection, projectionFor } from "../shared.ts";
+
+export function assertCodexModelRerouteOutput(
+ result: OrchestratorV2ScenarioResult,
+ transcript: ProviderReplayTranscript,
+) {
+ assertBaseProjection({
+ result,
+ transcript,
+ runCount: 2,
+ runStatuses: ["completed", "completed"],
+ });
+ const [first, second] = projectionFor(result, transcript.scenario).providerTurns;
+ // The reroute names the first turn only; without new settings the second turn's model is unknown.
+ assert.deepEqual(first?.turnTokenUsage?.models, ["gpt-6-astra"]);
+ assert.notProperty(second?.turnTokenUsage, "models");
+}
diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts
index 56d8938fa665b..089d8939fa417 100644
--- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts
+++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts
@@ -49,6 +49,7 @@ import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts";
import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts";
import { assertPiMultiTurnOutput } from "./multi_turn/pi_output.ts";
import { multiTurnInput } from "./multi_turn/input.ts";
+import { assertCodexModelRerouteOutput } from "./codex_model_reroute/output.ts";
import {
assertCodexPlanQuestionsOutput,
assertPlanQuestionsOutput,
@@ -681,6 +682,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.test.ts b/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.test.ts
new file mode 100644
index 0000000000000..02c51df5782b3
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.test.ts
@@ -0,0 +1,88 @@
+import { assert, it } from "@effect/vitest";
+import { OrchestrationV2AppThreadJson } from "@t3tools/contracts";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/sql/SqlClient";
+
+import { runMigrations } from "../Migrations.ts";
+
+const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" }));
+const createdAt = "2026-09-01T00:00:00.000Z";
+const decodeThreadPayload = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(OrchestrationV2AppThreadJson),
+);
+
+layer("077_RepairBooleanSettledOverride", (it) => {
+ it.effect("canonicalizes boolean settled overrides so recovery can decode them", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* runMigrations({ toMigrationInclusive: 76 });
+ const overrides: ReadonlyArray = [
+ ["true", true],
+ ["false", false],
+ ["settled", "settled"],
+ ["active", "active"],
+ ["null", null],
+ ["absent", undefined],
+ ];
+ for (const [id, settledOverride] of overrides) {
+ const payload = {
+ id,
+ projectId: "project-1",
+ title: id,
+ providerInstanceId: "codex",
+ modelSelection: { instanceId: "codex", model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ createdBy: "user",
+ creationSource: "web",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
+ forkedFrom: null,
+ createdAt,
+ updatedAt: createdAt,
+ archivedAt: null,
+ deletedAt: null,
+ settledOverride,
+ settledAt: settledOverride === true ? createdAt : null,
+ };
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_threads (
+ thread_id, project_id, title, default_provider, runtime_mode, interaction_mode,
+ created_at, updated_at, payload_json
+ ) VALUES (
+ ${id}, 'project-1', ${id}, 'codex', 'full-access', 'default',
+ ${createdAt}, ${createdAt}, ${JSON.stringify(payload)}
+ )
+ `;
+ }
+ const rows = sql<{ readonly thread_id: string; readonly payload_json: string }>`
+ SELECT thread_id, payload_json FROM orchestration_v2_projection_threads ORDER BY thread_id
+ `;
+ const before = new Map((yield* rows).map((row) => [row.thread_id, row.payload_json]));
+
+ yield* runMigrations({ toMigrationInclusive: 77 });
+ const after = new Map((yield* rows).map((row) => [row.thread_id, row.payload_json]));
+ for (const id of ["settled", "active", "null", "absent"]) {
+ assert.strictEqual(after.get(id), before.get(id));
+ }
+ const repaired = yield* sql`
+ SELECT thread_id,
+ json_type(payload_json, '$.settledOverride') AS type,
+ json_extract(payload_json, '$.settledOverride') AS settled_override,
+ json_extract(payload_json, '$.settledAt') AS settled_at
+ FROM orchestration_v2_projection_threads WHERE thread_id IN ('true', 'false')
+ ORDER BY thread_id
+ `;
+ assert.deepStrictEqual(repaired, [
+ { thread_id: "false", type: "null", settled_override: null, settled_at: null },
+ { thread_id: "true", type: "text", settled_override: "settled", settled_at: createdAt },
+ ]);
+ const thread = yield* decodeThreadPayload(after.get("true"));
+ assert.strictEqual(thread.settledOverride, "settled");
+ }),
+ );
+});
diff --git a/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.ts b/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.ts
new file mode 100644
index 0000000000000..e84473964fc61
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/077_RepairBooleanSettledOverride.ts
@@ -0,0 +1,14 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+// Thread projections may hold a boolean settledOverride, which the thread schema rejects.
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* sql`
+ UPDATE orchestration_v2_projection_threads
+ SET payload_json = json_set(payload_json, '$.settledOverride',
+ IIF(json_type(payload_json, '$.settledOverride') = 'true', 'settled', NULL))
+ WHERE json_valid(payload_json)
+ AND json_type(payload_json, '$.settledOverride') IN ('true', 'false')
+ `;
+});
diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
index 08c5094bf49c8..4083978758ce0 100644
--- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
+++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
@@ -56,6 +56,7 @@ describe("V2 preview upgrade", () => {
[74, "ExcludeAttachmentPlaceholdersFromSearch"],
[75, "SubagentDeliveryState"],
[76, "ProjectionProjectsPinArchive"],
+ [77, "RepairBooleanSettledOverride"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
@@ -155,6 +156,7 @@ describe("V2 preview upgrade", () => {
[74, "ExcludeAttachmentPlaceholdersFromSearch"],
[75, "SubagentDeliveryState"],
[76, "ProjectionProjectsPinArchive"],
+ [77, "RepairBooleanSettledOverride"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts
index 6a13af9ef9449..ed775e55a985a 100644
--- a/apps/server/src/project/ProjectCloneTracker.test.ts
+++ b/apps/server/src/project/ProjectCloneTracker.test.ts
@@ -4,6 +4,7 @@ import {
OrchestrationDispatchCommandError,
ProjectId,
projectCloneProgressSummary,
+ type SourceControlCloneProtocol,
SourceControlRepositoryError,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
@@ -146,12 +147,12 @@ describe("ProjectCloneTracker", () => {
});
it.effect("keeps a failed clone with git's own explanation and retries it", () => {
- let attempts = 0;
+ const protocols: Array = [];
const harness = makeHarness({
clone: (input) =>
Effect.suspend(() => {
- attempts += 1;
- return attempts === 1
+ protocols.push(input.protocol);
+ return protocols.length === 1
? Effect.fail(
new SourceControlRepositoryError({
operation: "cloneRepository",
@@ -168,7 +169,7 @@ describe("ProjectCloneTracker", () => {
});
return Effect.gen(function* () {
const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
- yield* tracker.start(startInput, harness.hooks);
+ yield* tracker.start({ ...startInput, protocol: "ssh" }, harness.hooks);
yield* Effect.yieldNow;
const failed = yield* tracker.get(projectId);
expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" });
@@ -177,7 +178,7 @@ describe("ProjectCloneTracker", () => {
expect(harness.discarded).toEqual(["/workspace/t3code"]);
yield* Effect.yieldNow;
expect((yield* tracker.get(projectId))?.phase).toBe("done");
- expect(attempts).toBe(2);
+ expect(protocols).toEqual(["ssh", "ssh"]);
}).pipe(Effect.provide(harness.layer));
});
diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts
index 4a0c99795155c..0a512bde2aa9b 100644
--- a/apps/server/src/project/ProjectCloneTracker.ts
+++ b/apps/server/src/project/ProjectCloneTracker.ts
@@ -5,6 +5,7 @@ import type {
ProjectCloneStartInput,
ProjectCloneStartResult,
ProjectId,
+ SourceControlCloneProtocol,
SourceControlRepositoryInfo,
} from "@t3tools/contracts";
import {
@@ -85,6 +86,7 @@ interface TrackedClone {
readonly cloneUrl: string;
readonly destinationPath: string;
readonly repository: SourceControlRepositoryInfo | null;
+ readonly protocol: SourceControlCloneProtocol;
};
}
@@ -216,7 +218,11 @@ export const make = Effect.gen(function* () {
const pulses = yield* Queue.sliding(1);
yield* watchForStall(projectId, pulses).pipe(Effect.forkScoped);
return yield* repositories.cloneRepository(
- { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath },
+ {
+ remoteUrl: tracked.input.cloneUrl,
+ destinationPath: tracked.input.destinationPath,
+ protocol: tracked.input.protocol,
+ },
{
onProgress: (update) =>
Queue.offer(pulses, undefined).pipe(Effect.andThen(progress(projectId, update))),
@@ -295,6 +301,7 @@ export const make = Effect.gen(function* () {
cloneUrl: prepared.cloneUrl,
destinationPath: prepared.destinationPath,
repository: prepared.repository,
+ protocol: input.protocol ?? "auto",
},
});
return [true, next] as const;
diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts
index 2266c415b726a..10f4a138ca23b 100644
--- a/apps/server/src/project/ProjectService.test.ts
+++ b/apps/server/src/project/ProjectService.test.ts
@@ -9,6 +9,7 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import { TestClock } from "effect/testing";
import * as SqlClient from "effect/sql/SqlClient";
@@ -102,6 +103,88 @@ const layerTestFor = (
const layerTest = layerTestFor(layerMetadata);
+it.effect("resolves only the requested active project page in creation order", () =>
+ Effect.gen(function* () {
+ const inspected = yield* Ref.make>(new Set());
+ const note = (workspaceRoot: string) =>
+ Ref.update(inspected, (roots) => new Set([...roots, workspaceRoot]));
+ const metadata = Layer.merge(
+ Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
+ resolve: (workspaceRoot) =>
+ note(workspaceRoot).pipe(
+ Effect.as({
+ canonicalKey: `example.test${workspaceRoot}`,
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "origin",
+ remoteUrl: `https://example.test${workspaceRoot}.git`,
+ },
+ rootPath: workspaceRoot,
+ }),
+ ),
+ }),
+ Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
+ invalidate: () => Effect.void,
+ resolvePath: (workspaceRoot) => note(workspaceRoot).pipe(Effect.as(null)),
+ }),
+ );
+ yield* Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const service = yield* ProjectService.ProjectService;
+ const enrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService;
+ const changes = yield* enrichment.subscribeChanges;
+ const rows = Array.from({ length: 6 }, (_, index) => ({
+ project_id: `page:${index}`,
+ title: `Project ${index}`,
+ workspace_root: `/work/page-${index}`,
+ scripts_json: "[]",
+ created_at: "2026-10-01T00:00:00.000Z",
+ updated_at: "2026-10-01T00:00:00.000Z",
+ deleted_at: index === 1 ? "2026-10-02T00:00:00.000Z" : null,
+ auto_pull: 0,
+ }));
+ yield* sql`INSERT INTO projection_projects ${sql.insert(rows)}`;
+ const first = yield* service.listPage({ cursor: 0, limit: 2 });
+ assert.deepEqual(
+ first.projects.map((project) => project.id),
+ ["page:0", "page:2"],
+ );
+ assert.equal(first.nextCursor, 2);
+ assert.deepEqual(
+ first.projects.map((project) => project.repositoryIdentity?.canonicalKey),
+ ["example.test/work/page-0", "example.test/work/page-2"],
+ );
+ // A sentinel queued after the page lets the enrichment workers finish
+ // earlier requests before checking which workspaces were inspected.
+ yield* enrichment.request("/work/page-sentinel");
+ for (;;) {
+ if ((yield* PubSub.take(changes)).workspaceRoot === "/work/page-sentinel") break;
+ }
+ assert.deepEqual(
+ new Set(yield* Ref.get(inspected)),
+ new Set(["/work/page-0", "/work/page-2", "/work/page-sentinel"]),
+ );
+ const second = yield* service.listPage({ cursor: first.nextCursor!, limit: 2 });
+ assert.deepEqual(
+ second.projects.map((project) => project.id),
+ ["page:3", "page:4"],
+ );
+ assert.equal(second.nextCursor, 4);
+ const last = yield* service.listPage({ cursor: second.nextCursor!, limit: 2 });
+ assert.deepEqual(
+ last.projects.map((project) => project.id),
+ ["page:5"],
+ );
+ assert.isNull(last.nextCursor);
+ assert.isFalse((yield* Ref.get(inspected)).has("/work/page-1"));
+ assert.deepEqual(yield* service.listPage({ cursor: 99, limit: 2 }), {
+ projects: [],
+ nextCursor: null,
+ });
+ }).pipe(Effect.scoped, Effect.provide(layerTestFor(metadata)));
+ }),
+);
+
const layerProjectServiceDependencies = Layer.mergeAll(
RuntimeLayer.layerEventSink,
ProjectStore.layer,
diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts
index 71c0e7c517f7f..a82ef6dd5e51e 100644
--- a/apps/server/src/project/ProjectService.ts
+++ b/apps/server/src/project/ProjectService.ts
@@ -555,14 +555,14 @@ export const make = Effect.gen(function* () {
return yield* Effect.forEach(shells, enrichShell, { concurrency: 16 });
});
+ const listRows = projects
+ .list()
+ .pipe(
+ Effect.mapError((cause) => new ProjectOperationError({ operation: "list-projects", cause })),
+ );
+
const snapshot = Effect.gen(function* () {
- const rows = yield* projects
- .list()
- .pipe(
- Effect.mapError(
- (cause) => new ProjectOperationError({ operation: "list-projects", cause }),
- ),
- );
+ const rows = yield* listRows;
const hydrated = yield* Effect.forEach(rows, hydrate, { concurrency: 8 });
return {
projects: hydrated,
@@ -572,15 +572,19 @@ export const make = Effect.gen(function* () {
const listPage: ProjectService["Service"]["listPage"] = Effect.fn("ProjectService.listPage")(
function* ({ cursor, limit }) {
- const rows = (yield* snapshot).projects.filter((project) => project.deletedAt === null);
+ const rows = (yield* listRows).filter((project) => project.deletedAt === null);
const projects = yield* Effect.forEach(
rows.slice(cursor, cursor + limit),
- (project) =>
- project.repositoryIdentity !== null
- ? Effect.succeed(project)
- : projectEnrichment
- .readRepositoryIdentity(project.workspaceRoot)
- .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))),
+ (row) =>
+ hydrate(row).pipe(
+ Effect.filterOrElse(
+ (project) => project.repositoryIdentity !== null,
+ (project) =>
+ projectEnrichment
+ .readRepositoryIdentity(project.workspaceRoot)
+ .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))),
+ ),
+ ),
{ concurrency: 8 },
);
return { projects, nextCursor: cursor + limit < rows.length ? cursor + limit : null };
diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts
index 0eaa641a72cc9..80f9952fbf714 100644
--- a/apps/server/src/provider/ClaudeProvider.ts
+++ b/apps/server/src/provider/ClaudeProvider.ts
@@ -38,7 +38,7 @@ import {
type ServerProviderDraft,
} from "@t3tools/provider-core/server/snapshotProbe";
import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts";
-import { discoverClaudeSkills, readClaudeSettingsBaseUrl } from "./Drivers/ClaudeSkills.ts";
+import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts";
import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver";
import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";
import {
@@ -649,7 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
// First-party account fields cannot verify custom endpoint credentials.
const baseUrl =
- (yield* readClaudeSettingsBaseUrl(claudeSettings, cwd, resolvedEnvironment)) ??
+ (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ??
resolvedEnvironment.ANTHROPIC_BASE_URL;
const endpoint = URL.parse(baseUrl?.trim() ?? "");
const customApiEndpoint =
diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts
index 79eaaa60a11ce..2fa6a11bac1e5 100644
--- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts
+++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts
@@ -49,12 +49,96 @@ function withEarlierSegmentUsage(
return total;
}
+/** Per-model totals from the SDK's `modelUsage`, which grows for the life of one CLI process. */
+export type ClaudeModelLedger = ReadonlyMap<
+ string,
+ { readonly input: number; readonly output: number }
+>;
+
+export interface ClaudeLedgerUsage {
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+}
+
+/** Unknown when absent or empty: a process that did model work never reports an empty ledger. */
+export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | undefined {
+ if (typeof modelUsage !== "object" || modelUsage === null) return undefined;
+ if (Object.keys(modelUsage).length === 0) return undefined;
+ const ledger = new Map();
+ for (const [model, entry] of Object.entries(modelUsage as Record)) {
+ const fields = (entry ?? {}) as Record;
+ const input = finiteNonNegativeInteger(fields.inputTokens);
+ const output = finiteNonNegativeInteger(fields.outputTokens);
+ if (input === undefined || output === undefined) return undefined;
+ const cacheRead = finiteNonNegativeInteger(fields.cacheReadInputTokens) ?? 0;
+ const cacheWrite = finiteNonNegativeInteger(fields.cacheCreationInputTokens) ?? 0;
+ ledger.set(model, { input: input + cacheRead + cacheWrite, output });
+ }
+ return ledger;
+}
+
+/** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */
+export function claudeLedgerDelta(
+ previous: ClaudeModelLedger | undefined,
+ current: ClaudeModelLedger | undefined,
+): ClaudeLedgerUsage | undefined {
+ if (previous === undefined || current === undefined) return undefined;
+ let inputTokens = 0;
+ let outputTokens = 0;
+ for (const [model, before] of previous) {
+ const after = current.get(model);
+ if (after === undefined || after.input < before.input || after.output < before.output) {
+ return undefined;
+ }
+ }
+ for (const [model, after] of current) {
+ const before = previous.get(model);
+ inputTokens += after.input - (before?.input ?? 0);
+ outputTokens += after.output - (before?.output ?? 0);
+ }
+ return { inputTokens, outputTokens };
+}
+
+export function addClaudeLedgerUsage(
+ left: ClaudeLedgerUsage | undefined,
+ right: ClaudeLedgerUsage | undefined,
+): ClaudeLedgerUsage | undefined {
+ return left === undefined || right === undefined
+ ? undefined
+ : {
+ inputTokens: left.inputTokens + right.inputTokens,
+ outputTokens: left.outputTokens + right.outputTokens,
+ };
+}
+
+export interface ClaudeTurnObservations {
+ // Models on the turn's root assistant messages.
+ readonly rootModels?: ReadonlySet;
+ // Ledger growth across every result this turn consumed, including steered segments.
+ readonly ledger?: ClaudeLedgerUsage | undefined;
+}
+
export function normalizeClaudeTurnTokenUsage(
- result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined,
+ result:
+ | {
+ readonly subtype: SDKResultMessage["subtype"];
+ readonly usage?: unknown;
+ readonly local_command?: unknown;
+ }
+ | undefined,
hasSubagents: boolean,
terminalStatus: RuntimeTurnState,
earlierSegmentUsage: ReadonlyArray = [],
+ observed: ClaudeTurnObservations = {},
): TurnTokenUsage {
+ const observedFields = {
+ ...(observed.rootModels !== undefined && observed.rootModels.size > 0
+ ? { models: [...observed.rootModels] }
+ : {}),
+ ...(typeof result?.local_command === "string" && result.local_command.length > 0
+ ? { localCommand: true }
+ : {}),
+ };
const finalUsage = result?.usage as Record | undefined;
const usage = withEarlierSegmentUsage(finalUsage ?? {}, earlierSegmentUsage);
const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens);
@@ -89,10 +173,25 @@ export function normalizeClaudeTurnTokenUsage(
usageStatus: "unavailable",
usageScope: "main_agent",
hasSubagents,
+ ...observedFields,
};
}
+ // The result omits subagent and compaction work that the process-wide ledger records.
+ const otherUsage =
+ observed.ledger !== undefined &&
+ inputTokens !== undefined &&
+ rawOutputTokens !== undefined &&
+ observed.ledger.inputTokens >= inputTokens &&
+ observed.ledger.outputTokens >= rawOutputTokens
+ ? {
+ inputTokens: observed.ledger.inputTokens - inputTokens,
+ outputTokens: observed.ledger.outputTokens - rawOutputTokens,
+ }
+ : undefined;
const commonUsage = {
+ ...observedFields,
+ ...(otherUsage === undefined ? {} : { otherUsage }),
usageScope: "main_agent",
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
diff --git a/apps/server/src/provider/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts
index 7b0f20a123ffe..7b2524464b0e0 100644
--- a/apps/server/src/provider/CodexTurnTokenUsage.ts
+++ b/apps/server/src/provider/CodexTurnTokenUsage.ts
@@ -133,27 +133,23 @@ export function accumulateCodexTurnTokenUsage(
}
}
+/** `model` is the thread model Codex last reported, which a reroute replaces. */
export function completeCodexTurnTokenUsage(
state: CodexTurnTokenUsageState,
turnId: string,
completed: boolean,
+ model?: string,
): TurnTokenUsage {
const usage = state.byTurnId.get(turnId);
state.byTurnId.delete(turnId);
if (state.activeTurnId === turnId) state.activeTurnId = undefined;
- if (!usage) {
+ const models = model === undefined ? {} : { models: [model] };
+ if (!usage?.observed) {
return {
usageStatus: "unavailable",
usageScope: "main_agent",
- hasSubagents: false,
- };
- }
-
- if (!usage.observed) {
- return {
- usageStatus: "unavailable",
- usageScope: "main_agent",
- hasSubagents: usage.hasSubagents,
+ hasSubagents: usage?.hasSubagents ?? false,
+ ...models,
};
}
@@ -169,5 +165,6 @@ export function completeCodexTurnTokenUsage(
outputTokens: usage.outputTokens,
reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens),
hasSubagents: usage.hasSubagents,
+ ...models,
};
}
diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts
index 2ebc6f40775c8..803415a73e569 100644
--- a/apps/server/src/provider/Drivers/ClaudeDriver.ts
+++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts
@@ -39,7 +39,6 @@ import {
type ProviderInstance,
} from "@t3tools/provider-core/server/driver";
import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
-import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import {
enrichProviderSnapshotWithVersionAdvisory,
makeCachedProviderMaintenanceResolution,
@@ -52,7 +51,11 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "@t3tools/provider-core/server/snapshotSettings";
-import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
+import {
+ makeClaudeCapabilitiesCacheKey,
+ makeClaudeContinuationGroupKey,
+ mergeClaudeProcessEnvironment,
+} from "./ClaudeHome.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
@@ -124,7 +127,7 @@ export const ClaudeDriver: ProviderDriver {
}),
);
});
+
+ it.effect(
+ "drops the launching Claude session and labels processes t3-code unless overridden",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const homePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" });
+ const host = {
+ CLAUDE_CODE_ENTRYPOINT: "claude-desktop",
+ CLAUDECODE: "1",
+ CLAUDE_CODE_SESSION_ID: "host-session",
+ CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/host-claude.sock",
+ CLAUDE_EFFORT: "high",
+ KEEP: "1",
+ };
+ const instance = [
+ { name: "CLAUDE_CODE_ENTRYPOINT", value: "instance", sensitive: false },
+ { name: "CLAUDE_EFFORT", value: "low", sensitive: false },
+ ];
+
+ expect(yield* mergeClaudeProcessEnvironment({ homePath }, undefined, host)).toEqual({
+ CLAUDE_CODE_ENTRYPOINT: "t3-code",
+ KEEP: "1",
+ });
+ expect(yield* mergeClaudeProcessEnvironment({ homePath }, instance, host)).toEqual({
+ CLAUDE_CODE_ENTRYPOINT: "instance",
+ CLAUDE_EFFORT: "low",
+ KEEP: "1",
+ });
+
+ yield* fileSystem.writeFileString(
+ path.join(homePath, "settings.json"),
+ JSON.stringify({ env: { CLAUDE_CODE_ENTRYPOINT: "custom" } }),
+ );
+ expect(
+ (yield* mergeClaudeProcessEnvironment({ homePath }, instance, host))
+ .CLAUDE_CODE_ENTRYPOINT,
+ ).toBe("custom");
+ }),
+ );
});
diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts
index 9b3e11c04b07c..4f6587ce83d9d 100644
--- a/apps/server/src/provider/Drivers/ClaudeHome.ts
+++ b/apps/server/src/provider/Drivers/ClaudeHome.ts
@@ -1,12 +1,15 @@
-import type { ClaudeSettings } from "@t3tools/contracts";
+import type { ClaudeSettings, ProviderInstanceEnvironment } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
+import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as HostProcess from "@t3tools/shared/HostProcess";
+import { readClaudeSettingsEnv } from "./ClaudeSkills.ts";
+
const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String));
export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* (
@@ -42,6 +45,45 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
};
});
+// A Claude Code session that launched T3 exports these. A claude spawned with them acts as part of
+// that session: its session id, messaging socket and token, and effort.
+const CLAUDE_HOST_SESSION_VARIABLES = [
+ "CLAUDECODE",
+ "CLAUDE_CODE_SESSION_ID",
+ "CLAUDE_CODE_HOST_SESSION_ID",
+ "CLAUDE_CODE_CHILD_SESSION",
+ "CLAUDE_CODE_SESSION_ATTENDED",
+ "CLAUDE_CODE_MESSAGING_SOCKET",
+ "CLAUDE_CODE_MESSAGING_TOKEN",
+ "CLAUDE_CODE_EXECPATH",
+ "CLAUDE_CODE_DESKTOP_APP_VERSION",
+ "CLAUDE_EFFORT",
+ "CLAUDE_PID",
+ "CLAUDE_AGENT_SDK_VERSION",
+];
+
+/**
+ * Base env for the Claude processes T3 Code runs, before `makeClaudeEnvironment`.
+ * Claude Code reads its entrypoint from the process env, not its settings `env`, so T3 Code applies that override itself.
+ */
+export const mergeClaudeProcessEnvironment = Effect.fn("mergeClaudeProcessEnvironment")(function* (
+ config: Pick,
+ instanceEnvironment: ProviderInstanceEnvironment | undefined,
+ hostEnvironment: NodeJS.ProcessEnv,
+) {
+ const baseEnvironment: NodeJS.ProcessEnv = {
+ ...hostEnvironment,
+ CLAUDE_CODE_ENTRYPOINT: "t3-code",
+ };
+ for (const name of CLAUDE_HOST_SESSION_VARIABLES) delete baseEnvironment[name];
+ const environment = yield* mergeProviderInstanceEnvironment(instanceEnvironment, baseEnvironment);
+ const settingsEntrypoint = (yield* readClaudeSettingsEnv(config, undefined, environment))
+ .CLAUDE_CODE_ENTRYPOINT;
+ return settingsEntrypoint
+ ? { ...environment, CLAUDE_CODE_ENTRYPOINT: settingsEntrypoint }
+ : environment;
+});
+
/** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */
export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts
index f0bc05eaeaa91..032725b96e33b 100644
--- a/apps/server/src/provider/Drivers/ClaudeSkills.ts
+++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts
@@ -429,24 +429,25 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct
return path.join(home, ".claude");
});
-const SettingsBaseUrl = fromLenientJson(
- Schema.Struct({
- env: Schema.optional(Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String) })),
- }),
+const SettingsEnv = Schema.Struct({
+ ANTHROPIC_BASE_URL: Schema.optional(Schema.String),
+ CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String),
+});
+const decodeSettingsEnv = Schema.decodeUnknownEffect(
+ fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })),
);
-const decodeSettingsBaseUrl = Schema.decodeUnknownEffect(SettingsBaseUrl);
-/** ANTHROPIC_BASE_URL from Claude's settings `env`, which Claude Code applies over the process env. */
-export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")(function* (
+/** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */
+export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* (
config: Pick,
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
-): Effect.fn.Return {
+): Effect.fn.Return {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcess.Platform;
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd);
- let baseUrl: string | undefined;
+ const settingsEnv: typeof SettingsEnv.Type = {};
for (const settingsPath of claudeSettingsPaths(
path,
configDirPath,
@@ -458,12 +459,10 @@ export const readClaudeSettingsBaseUrl = Effect.fn("readClaudeSettingsBaseUrl")(
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) continue;
- const parsed = yield* decodeSettingsBaseUrl(contents).pipe(
- Effect.orElseSucceed(() => undefined),
- );
- baseUrl = parsed?.env?.ANTHROPIC_BASE_URL ?? baseUrl;
+ const parsed = yield* decodeSettingsEnv(contents).pipe(Effect.orElseSucceed(() => undefined));
+ Object.assign(settingsEnv, parsed?.env);
}
- return baseUrl;
+ return settingsEnv;
});
// Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value.
diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts
index 2f9cffbc757fa..dfba5551f5c92 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.test.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts
@@ -216,6 +216,24 @@ it.layer(layerTest)("CodexDriver", (it) => {
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped),
);
+ it.effect("uses each instance's environment CODEX_HOME for continuation", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" });
+ for (const [index, account] of ["work", "personal", "work "].entries()) {
+ const codexHome = NodePath.join(tempDir, account);
+ const instance = yield* CodexDriver.create({
+ instanceId: ProviderInstanceId.make(`account-${index}`),
+ displayName: account,
+ enabled: false,
+ environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }],
+ config: CodexDriver.defaultConfig(),
+ });
+ expect(instance.continuationIdentity.continuationKey).toBe(`codex:home:${codexHome}`);
+ }
+ }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped),
+ );
+
for (const fixture of [
{
name: "leaves mise npm-backend installations manual-only",
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 64dc57ec06400..fe324ff2503ff 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -123,8 +123,16 @@ export const CodexDriver: ProviderDriver
+ Effect.gen(function* () {
+ const claudeDriver = ProviderDriverKind.make("claude");
+ const claudeInstanceId = ProviderInstanceId.make("claude");
+ const initialProvider = {
+ instanceId: claudeInstanceId,
+ driver: claudeDriver,
+ status: "ready",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ checkedAt: "2026-04-14T00:00:00.000Z",
+ version: "2.1.300",
+ models: [],
+ slashCommands: [],
+ skills: [],
+ } as const satisfies ServerProvider;
+ const verifyingProvider = { ...initialProvider, checkedAt: "2026-04-14T00:01:00.000Z" };
+ const laterProvider = { ...initialProvider, checkedAt: "2026-04-14T00:02:00.000Z" };
+ const changes = yield* PubSub.unbounded();
+ const instance = {
+ instanceId: claudeInstanceId,
+ driverKind: claudeDriver,
+ continuationIdentity: {
+ driverKind: claudeDriver,
+ continuationKey: "claude:instance:claude",
+ },
+ displayName: undefined,
+ enabled: true,
+ snapshot: {
+ resolveMaintenance: () =>
+ Effect.succeed(
+ makeManualOnlyProviderMaintenanceCapabilities({
+ provider: claudeDriver,
+ packageName: null,
+ }),
+ ),
+ getSnapshot: Effect.succeed(initialProvider),
+ refresh: Effect.succeed(initialProvider),
+ streamChanges: Stream.fromPubSub(changes),
+ applyUsageLimits: () => Effect.void,
+ },
+ orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
+ textGeneration: {} as ProviderInstance["textGeneration"],
+ } satisfies ProviderInstance;
+ const layerInstanceRegistry = Layer.succeed(
+ ProviderInstanceRegistry.ProviderInstanceRegistry,
+ {
+ getInstance: (instanceId) =>
+ Effect.succeed(instanceId === claudeInstanceId ? instance : undefined),
+ listInstances: Effect.succeed([instance]),
+ listUnavailable: Effect.succeed([]),
+ streamChanges: Stream.empty,
+ subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) =>
+ PubSub.subscribe(pubsub),
+ ),
+ },
+ );
+ const writeStarted = yield* Deferred.make();
+ const releaseWrite = yield* Deferred.make();
+ const gatedCachePath = yield* Ref.make(undefined);
+ const layerGatedFileSystem = Layer.effect(
+ FileSystem.FileSystem,
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return {
+ ...fileSystem,
+ rename: (from, to) =>
+ Ref.modify(gatedCachePath, (path) =>
+ path === to ? [true, undefined] : [false, path],
+ ).pipe(
+ Effect.flatMap((gated) =>
+ gated
+ ? Deferred.succeed(writeStarted, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseWrite)),
+ )
+ : Effect.void,
+ ),
+ Effect.andThen(fileSystem.rename(from, to)),
+ ),
+ } satisfies FileSystem.FileSystem;
+ }),
+ ).pipe(Layer.provide(NodeServices.layer));
+ const scope = yield* Scope.make();
+ yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
+ const runtimeServices = yield* Layer.build(
+ ProviderRegistry.layer.pipe(
+ Layer.provide(layerUnusedMutator),
+ Layer.provideMerge(layerInstanceRegistry),
+ Layer.provideMerge(
+ ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3-provider-registry-update-state-race-",
+ }),
+ ),
+ Layer.provideMerge(layerBackgroundPolicyAlwaysRun),
+ Layer.provideMerge(layerGatedFileSystem),
+ Layer.provideMerge(NodeServices.layer),
+ ),
+ ).pipe(Scope.provide(scope));
+
+ yield* Effect.gen(function* () {
+ const registry = yield* ProviderRegistry.ProviderRegistry;
+ const config = yield* ServerConfig.ServerConfig;
+ const running = {
+ status: "running",
+ startedAt: "2026-04-14T00:00:30.000Z",
+ finishedAt: null,
+ message: "Verifying the installed version",
+ output: null,
+ } as const;
+ const unchanged = {
+ ...running,
+ status: "unchanged",
+ finishedAt: "2026-04-14T00:01:30.000Z",
+ message:
+ "Update command completed, but T3 Code still detects an outdated provider version.",
+ } as const;
+ yield* registry.setProviderMaintenanceActionState({
+ instanceId: claudeInstanceId,
+ action: "update",
+ state: running,
+ });
+ const streamed = yield* registry.streamChanges.pipe(
+ Stream.takeUntil((providers) =>
+ providers.some((provider) => provider.checkedAt === laterProvider.checkedAt),
+ ),
+ Stream.runCollect,
+ Effect.forkScoped({ startImmediately: true }),
+ );
+
+ yield* Ref.set(
+ gatedCachePath,
+ yield* resolveProviderStatusCachePath({
+ cacheDir: config.providerStatusCacheDir,
+ instanceId: claudeInstanceId,
+ }),
+ );
+ yield* PubSub.publish(changes, verifyingProvider);
+ yield* Deferred.await(writeStarted);
+ yield* registry.setProviderMaintenanceActionState({
+ instanceId: claudeInstanceId,
+ action: "update",
+ state: unchanged,
+ });
+ yield* Deferred.succeed(releaseWrite, undefined);
+ // The snapshot stream is consumed in order, so this lands after the slow write publishes.
+ yield* PubSub.publish(changes, laterProvider);
+
+ const updateStateOf = (providers: ReadonlyArray | undefined) =>
+ providers?.find((provider) => provider.instanceId === claudeInstanceId)?.updateState;
+ const beforeLater = (yield* Fiber.join(streamed)).slice(0, -1);
+ assert.deepStrictEqual(updateStateOf(yield* registry.getProviders), unchanged);
+ assert.deepStrictEqual(
+ updateStateOf(beforeLater.at(-1)),
+ unchanged,
+ "the last streamed providers before the next snapshot should carry the terminal update state",
+ );
+ }).pipe(Effect.provide(runtimeServices));
+ }),
+ );
+
it.effect(
"persists authoritative OpenCode removals without resurrecting them on a failed live refresh",
() =>
diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts
index 3234bfd61960a..e8a13a0617c34 100644
--- a/apps/server/src/provider/ProviderRegistry.ts
+++ b/apps/server/src/provider/ProviderRegistry.ts
@@ -524,7 +524,8 @@ export const layer = Layer.effect(
});
}
if (options?.publish !== false) {
- yield* PubSub.publish(changesPubSub, providers);
+ // A newer upsert may have committed and published during the cache write; never republish an older array.
+ yield* PubSub.publish(changesPubSub, yield* Ref.get(providersRef));
}
}
diff --git a/apps/server/src/provider/TurnTokenUsage.test.ts b/apps/server/src/provider/TurnTokenUsage.test.ts
index 06802143e73c1..fb14511211e36 100644
--- a/apps/server/src/provider/TurnTokenUsage.test.ts
+++ b/apps/server/src/provider/TurnTokenUsage.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vite-plus/test";
-import { normalizeClaudeTurnTokenUsage } from "./ClaudeTurnTokenUsage.ts";
+import {
+ claudeLedgerDelta,
+ claudeModelLedger,
+ normalizeClaudeTurnTokenUsage,
+} from "./ClaudeTurnTokenUsage.ts";
import {
accumulateCodexTurnTokenUsage,
completeCodexTurnTokenUsage,
@@ -209,6 +213,15 @@ describe("billable native turn usage", () => {
},
);
+ it("measures Claude ledger growth only from a known, nondecreasing baseline", () => {
+ const ledger = (output: number) =>
+ claudeModelLedger({ "claude-sonnet-4-6": { inputTokens: 100, outputTokens: output } });
+ expect(claudeLedgerDelta(ledger(10), ledger(25))).toEqual({ inputTokens: 0, outputTokens: 15 });
+ expect(claudeLedgerDelta(ledger(25), ledger(10))).toBeUndefined();
+ expect(claudeLedgerDelta(undefined, ledger(10))).toBeUndefined();
+ expect(claudeModelLedger({})).toBeUndefined();
+ });
+
it("adds Claude steering segments to the final measured result once", () => {
expect(
normalizeClaudeTurnTokenUsage(
diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json
index 87ed61776ea9c..59753c7f51458 100644
--- a/apps/server/src/provider/model-manifest.json
+++ b/apps/server/src/provider/model-manifest.json
@@ -75,8 +75,9 @@
{
"driver": "antigravity",
"t3CodeRange": ">=0.0.42",
- "recommendedRange": "=1.4.1",
+ "recommendedRange": "=1.4.5 || =1.4.1",
"ranges": [
+ { "range": "=1.4.5", "status": "supported" },
{ "range": "=1.4.1", "status": "supported" },
{ "range": "=1.3.0", "status": "supported" },
{ "range": "=1.1.1", "status": "supported" }
diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts
index ca8b81f188fba..24ecf7efb03ea 100644
--- a/apps/server/src/storageCleanup.test.ts
+++ b/apps/server/src/storageCleanup.test.ts
@@ -2111,6 +2111,36 @@ describe("storage cleanup reports and local file policies", () => {
}),
),
);
+ it.live.each([
+ ["unchanged", "removed", "No commits beyond the project branch"],
+ ["unmerged", "kept", "Has commits beyond the project branch"],
+ ["detached", "kept", "Project branch is unavailable"],
+ ["merged-only", "kept", "No remote is configured for merged-worktree cleanup"],
+ ] as const)("cleans archived local-only worktrees: %s", ([state, outcome, reason]) =>
+ runCleanupTest(
+ Effect.gen(function* () {
+ const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({
+ ...DEFAULT_SERVER_SETTINGS.storageCleanup,
+ worktreeUnchanged: state !== "merged-only",
+ worktreeOnMerge: true,
+ });
+ yield* command(repo, ["branch", "-m", "main", "trunk"]);
+ setThreads([shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) })]);
+ if (state === "unmerged") {
+ yield* fs.writeFileString(`${worktree}/tracked.txt`, "unmerged work\n");
+ yield* command(worktree, ["commit", "-am", "unmerged work"]);
+ }
+ if (state === "detached") yield* command(repo, ["checkout", "--detach"]);
+
+ const report = yield* service.runNow;
+ expect(report.entries[0]).toMatchObject({ outcome, reason });
+ expect(yield* fs.exists(worktree)).toBe(outcome === "kept");
+ expect(
+ (yield* command(repo, ["show-ref", "--verify", "refs/heads/feature"])).stdout,
+ ).not.toBe("");
+ }),
+ ),
+ );
it.live(
"discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree",
() =>
diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts
index 95a8a2f7b8c74..7b41505b4e5d2 100644
--- a/apps/server/src/storageCleanup.ts
+++ b/apps/server/src/storageCleanup.ts
@@ -892,42 +892,66 @@ const make = Effect.gen(function* () {
: `settled for ${settledDays} days`;
if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
const repositoryCwd = path.resolve(project.workspaceRoot);
- const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
- const defaultBranchKey = `${repositoryCwd}\0${remote}`;
- let branch = defaultBranches.get(defaultBranchKey);
- if (branch === undefined) {
- branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
- // Without a local HEAD ref, only the remote can identify its default;
- // a conventional name may be a feature branch.
- if (branch === null) {
- branch = yield* git
- .execute({
- operation: "StorageCleanup.resolveDefaultBranch",
- cwd: repositoryCwd,
- args: ["ls-remote", "--symref", "--", remote, "HEAD"],
- timeoutMs: 20_000,
- })
- .pipe(
- Effect.map(
- (result) =>
- /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null,
- ),
- Effect.orElseSucceed(() => null),
- );
- }
- defaultBranches.set(defaultBranchKey, branch);
- }
- if (branch === null) return keep("default branch is unavailable");
- const defaultRef = `refs/remotes/${remote}/${branch}`;
- const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set();
- if (!refreshed.has(defaultRef)) {
- yield* git.fetchRemoteTrackingBranch({
+ const remotes = yield* git.execute({
+ operation: "StorageCleanup.listRemotes",
+ cwd: repositoryCwd,
+ args: ["remote"],
+ });
+ const remote =
+ remotes.stdout.trim() === ""
+ ? null
+ : yield* git.resolvePrimaryRemoteName(repositoryCwd);
+ let branch: string | null | undefined;
+ let defaultRef: string;
+ if (remote === null) {
+ if (!settings.worktreeUnchanged)
+ return keep("no remote is configured for merged-worktree cleanup");
+ const projectHead = yield* git.execute({
+ operation: "StorageCleanup.resolveProjectBranch",
cwd: repositoryCwd,
- remoteName: remote,
- remoteBranch: branch,
+ args: ["symbolic-ref", "--quiet", "HEAD"],
+ allowNonZeroExit: true,
});
- refreshed.add(defaultRef);
- refreshedDefaultRefs.set(repositoryCwd, refreshed);
+ if (projectHead.exitCode !== 0) return keep("project branch is unavailable");
+ defaultRef = projectHead.stdout.trim();
+ branch = defaultRef.slice("refs/heads/".length);
+ } else {
+ const defaultBranchKey = `${repositoryCwd}\0${remote}`;
+ branch = defaultBranches.get(defaultBranchKey);
+ if (branch === undefined) {
+ branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
+ // Without a local HEAD ref, only the remote can identify its default;
+ // a conventional name may be a feature branch.
+ if (branch === null) {
+ branch = yield* git
+ .execute({
+ operation: "StorageCleanup.resolveDefaultBranch",
+ cwd: repositoryCwd,
+ args: ["ls-remote", "--symref", "--", remote, "HEAD"],
+ timeoutMs: 20_000,
+ })
+ .pipe(
+ Effect.map(
+ (result) =>
+ /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null,
+ ),
+ Effect.orElseSucceed(() => null),
+ );
+ }
+ defaultBranches.set(defaultBranchKey, branch);
+ }
+ if (branch === null) return keep("default branch is unavailable");
+ defaultRef = `refs/remotes/${remote}/${branch}`;
+ const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set();
+ if (!refreshed.has(defaultRef)) {
+ yield* git.fetchRemoteTrackingBranch({
+ cwd: repositoryCwd,
+ remoteName: remote,
+ remoteBranch: branch,
+ });
+ refreshed.add(defaultRef);
+ refreshedDefaultRefs.set(repositoryCwd, refreshed);
+ }
}
const base = yield* git.resolveCommit({
cwd: worktreePath,
@@ -941,8 +965,12 @@ const make = Effect.gen(function* () {
});
const integrated = ancestor.exitCode === 0;
eligible = integrated && settings.worktreeUnchanged;
- if (eligible) removalReason = "no commits beyond the default branch";
- if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
+ if (eligible)
+ removalReason =
+ remote === null
+ ? "no commits beyond the project branch"
+ : "no commits beyond the default branch";
+ if (!eligible && remote !== null && settings.worktreeOnMerge && thread.branch !== null) {
const pullRequest = yield* gitManager.branchPullRequest(
{ cwd: worktreePath, branch: thread.branch },
{ refresh: true },
@@ -955,6 +983,7 @@ const make = Effect.gen(function* () {
});
if (eligible) removalReason = "pull request was merged";
}
+ if (!eligible && remote === null) return keep("has commits beyond the project branch");
}
if (!eligible)
return keep(
diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.ts
new file mode 100644
index 0000000000000..88332999103f8
--- /dev/null
+++ b/apps/server/src/telemetry/ProviderUsageAnalytics.ts
@@ -0,0 +1,81 @@
+/**
+ * Provider token reporting: one `provider.turn.completed` per provider turn and one
+ * `provider.request.completed` per run, built from the same turns so each counts once.
+ *
+ * @module ProviderUsageAnalytics
+ */
+import type {
+ ModelSelection,
+ OrchestrationV2Actor,
+ OrchestrationV2CreationSource,
+ OrchestrationV2ProviderTurn,
+ OrchestrationV2RunAttempt,
+ ProviderInteractionMode,
+ RunId,
+ RuntimeMode,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import type * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+
+// Coder: no T3-owned telemetry. Only the report shapes and the no-op `ProviderTurnAnalytics`
+// reference stay, because carried orchestration files import them; the analytics layer, request
+// ledger, and property builders are cut.
+
+/**
+ * Where the run's starting message came from. Most turns start without a
+ * person (provider continuations, agent sends, delegated subagents), so
+ * reports need this to tell user turns apart.
+ */
+export interface ProviderTurnOrigin {
+ readonly createdBy: OrchestrationV2Actor;
+ readonly creationSource: OrchestrationV2CreationSource;
+ readonly scheduled: boolean;
+ readonly subagent: boolean;
+}
+
+/** What started this provider turn inside its run. */
+export type ProviderTurnSegment =
+ | OrchestrationV2RunAttempt["reason"]
+ | "continuation"
+ | "native_child";
+
+export interface ProviderTurnAnalyticsContext {
+ readonly runId?: RunId;
+ readonly modelSelection: ModelSelection;
+ readonly runtimeMode?: RuntimeMode;
+ readonly interactionMode?: ProviderInteractionMode;
+ readonly origin?: ProviderTurnOrigin;
+ readonly segment?: ProviderTurnSegment;
+ readonly restartContinuation?: boolean;
+}
+
+export interface ProviderTurnReport {
+ readonly driver: string;
+ readonly providerTurn: OrchestrationV2ProviderTurn;
+ readonly context?: ProviderTurnAnalyticsContext;
+}
+
+export interface RunSettledReport {
+ readonly runId: RunId;
+ readonly status: OrchestrationV2ProviderTurn["status"];
+ readonly completedAt: DateTime.Utc;
+ // Run-owned children were still open when the run settled; their later turns are not added.
+ readonly childrenRunning: boolean;
+}
+
+export class ProviderTurnAnalytics extends Context.Reference<{
+ readonly runStarted: (input: {
+ readonly runId: RunId;
+ readonly driver: string;
+ readonly context: ProviderTurnAnalyticsContext;
+ }) => Effect.Effect;
+ readonly turnCompleted: (report: ProviderTurnReport) => Effect.Effect;
+ readonly runSettled: (report: RunSettledReport) => Effect.Effect;
+}>("t3/telemetry/ProviderTurnAnalytics", {
+ defaultValue: () => ({
+ runStarted: () => Effect.void,
+ turnCompleted: () => Effect.void,
+ runSettled: () => Effect.void,
+ }),
+}) {}
diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts
index 3ea0502f83b33..197d412d93405 100644
--- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts
+++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts
@@ -57,6 +57,7 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) {
function withFakeClaudeEnv(
input: {
output: string;
+ rejectsNoSessionPersistence?: boolean;
lifecyclePort?: number;
exitCode?: number;
stderr?: string;
@@ -75,6 +76,8 @@ function withFakeClaudeEnv (
const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort);
const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":";
const previousPath = process.env.PATH;
+ const previousRejectsNoSessionPersistence =
+ process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
@@ -89,6 +92,12 @@ function withFakeClaudeEnv (
process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`;
process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
+ if (input.rejectsNoSessionPersistence) {
+ process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = "1";
+ } else {
+ delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
+ }
+
if (input.exitCode !== undefined) {
process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
} else {
@@ -135,6 +144,13 @@ function withFakeClaudeEnv (
Effect.sync(() => {
process.env.PATH = previousPath;
+ if (previousRejectsNoSessionPersistence === undefined) {
+ delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
+ } else {
+ process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE =
+ previousRejectsNoSessionPersistence;
+ }
+
if (previousOutput === undefined) {
delete process.env.T3_FAKE_CLAUDE_OUTPUT;
} else {
@@ -320,6 +336,28 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {
}),
);
+ it.effect("retries without --no-session-persistence when the Claude CLI rejects it", () =>
+ withFakeClaudeEnv(
+ {
+ output: JSON.stringify({ structured_output: { title: "Reconnect failures" } }),
+ rejectsNoSessionPersistence: true,
+ },
+ (textGeneration) =>
+ Effect.gen(function* () {
+ const generated = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "Investigate reconnect failures",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
+ },
+ });
+
+ expect(generated.title).toBe("Reconnect failures");
+ }),
+ ),
+ );
+
it.effect.each([
{ verbose: false, label: "normal" },
{ verbose: true, label: "verbose" },
@@ -430,6 +468,14 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {
apiError,
],
["stderr when stdout has no error", "not json", wrapperNotice],
+ [
+ "a stdout error naming --no-session-persistence without retrying",
+ JSON.stringify({
+ is_error: true,
+ result: "error: unknown option '--no-session-persistence'",
+ }),
+ "error: unknown option '--no-session-persistence'",
+ ],
] as const)("reports %s when the Claude CLI fails", ([, output, expected]) =>
withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) =>
Effect.gen(function* () {
diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts
index 51bbd38ae69eb..5d6adbd1e9cb7 100644
--- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts
+++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts
@@ -49,6 +49,10 @@ const CLAUDE_INVESTIGATION_ALLOWED = [
// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere.
const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(",");
+const NO_SESSION_PERSISTENCE = "--no-session-persistence";
+// Claude Code before 2.0.63 exits with this on stderr; those runs retry without the flag.
+const NO_SESSION_PERSISTENCE_REJECTED = `error: unknown option '${NO_SESSION_PERSISTENCE}'`;
+
const ClaudeOutputEnvelope = Schema.Struct({
structured_output: Schema.Unknown,
});
@@ -192,7 +196,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
"Failed to encode Claude CLI settings.",
);
- const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
+ const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* (
+ noSessionPersistence: boolean,
+ ) {
const workingDirectory =
operation === "generateThreadTitle"
? yield* fileSystem
@@ -228,6 +234,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
]
: ["--tools", ""]),
"--disable-slash-commands",
+ ...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []),
// managed-mcp.json already isolates servers and makes Claude reject this flag.
...(managedMcpConfig ? [] : ["--strict-mcp-config"]),
"--permission-mode",
@@ -266,25 +273,32 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
{ concurrency: "unbounded" },
);
- if (exitCode !== 0) {
+ return { stdout, stderr, exitCode };
+ });
+
+ const rawStdout = yield* runClaudeCommand(true).pipe(
+ Effect.filterOrElse(
+ (result) =>
+ result.exitCode === 0 || !result.stderr.includes(NO_SESSION_PERSISTENCE_REJECTED),
+ () => runClaudeCommand(false),
+ ),
+ Effect.flatMap(({ stdout, stderr, exitCode }) => {
+ if (exitCode === 0) return Effect.succeed(stdout);
const stderrDetail = stderr.trim();
const stdoutDetail = stdout.trim();
const detail =
claudeErrorResult(stdoutDetail) ??
(stderrDetail.length > 0 ? stderrDetail : stdoutDetail);
- return yield* new TextGenerationError({
- operation,
- detail:
- detail.length > 0
- ? `Claude CLI command failed: ${detail}`
- : `Claude CLI command failed with code ${exitCode}.`,
- });
- }
-
- return stdout;
- });
-
- const rawStdout = yield* runClaudeCommand().pipe(
+ return Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail:
+ detail.length > 0
+ ? `Claude CLI command failed: ${detail}`
+ : `Claude CLI command failed with code ${exitCode}.`,
+ }),
+ );
+ }),
Effect.scoped,
Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS),
Effect.flatMap(
diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs
index d5ca2f185eb8d..22d406a4dcba0 100644
--- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs
+++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs
@@ -25,6 +25,13 @@ if (!argv.includes("--disable-slash-commands")) {
if (!argv.includes("--strict-mcp-config")) {
fail("text generation must not load configured MCP servers", 9);
}
+if (process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE === "1") {
+ if (argv.includes("--no-session-persistence")) {
+ fail("error: unknown option '--no-session-persistence'", 1);
+ }
+} else if (!argv.includes("--no-session-persistence")) {
+ fail("text generation must not save a resumable session", 16);
+}
const settingsIndex = argv.indexOf("--settings");
if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) {
fail("text generation must disable hooks", 10);
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index d9a98dd871c8b..0ab293a7a7d9d 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -297,6 +297,11 @@ export interface GitWriteConfigValueInput {
readonly value: string;
}
+export interface GitWorktreeBaseRemote {
+ remoteName: string;
+ branchName: string;
+}
+
export interface GitSetBranchUpstreamInput {
cwd: string;
branch: string;
@@ -406,10 +411,13 @@ export class GitVcsDriver extends Context.Service<
) => Effect.Effect;
readonly resolveWorktreeBaseRemote: (
input: GitFetchWorktreeBaseCommitInput,
- ) => Effect.Effect<{ remoteName: string; branchName: string } | null, GitCommandError>;
- /** Fetches the configured upstream, explicit remote ref, or origin; null uses the local base. */
+ ) => Effect.Effect;
+ /**
+ * Fetches the configured upstream, explicit remote ref, or origin; null uses the local base.
+ * A caller that already ran `resolveWorktreeBaseRemote` passes its result as `remote`.
+ */
readonly fetchWorktreeBaseCommit: (
- input: GitFetchWorktreeBaseCommitInput,
+ input: GitFetchWorktreeBaseCommitInput & { readonly remote?: GitWorktreeBaseRemote },
) => Effect.Effect;
readonly fetchRemoteBranch: (
input: GitFetchRemoteBranchInput,
@@ -1383,6 +1391,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
operation,
cwd: input.cwd,
args: [
+ // Not --no-relative, which Git before 2.28 rejects.
+ "-c",
+ "diff.relative=false",
"diff",
...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]),
"--no-color",
@@ -1439,7 +1450,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
execute({
operation,
cwd: input.cwd,
- args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"],
+ args: [
+ "-c",
+ "diff.relative=false",
+ ...args,
+ "--name-only",
+ "-z",
+ "--no-renames",
+ "--no-ext-diff",
+ ],
maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
outputMode: "error",
}).pipe(Effect.map((result) => result.stdout.split("\0")));
diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts
index f64ade1219bdf..56b9547fad5b0 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.test.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts
@@ -317,22 +317,24 @@ it.effect.each(["root", "nested", "worktree"] as const)(
}).pipe(Effect.provide(layerTest)),
);
-it.effect("invalidates origin remote cache when a driver mutation adds origin", () =>
- Effect.gen(function* () {
- const driver = yield* GitVcsDriver.GitVcsDriver;
- const cwd = yield* makeTmpDir();
- const remote = yield* makeTmpDir("git-vcs-driver-remote-");
- yield* initRepoWithCommit(cwd);
- yield* git(remote, ["init", "--bare"]);
+it.effect.each(["origin", "github"] as const)(
+ "recognizes %s as the primary remote after a driver mutation",
+ (remoteName) =>
+ Effect.gen(function* () {
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+ const cwd = yield* makeTmpDir();
+ const remote = yield* makeTmpDir("git-vcs-driver-remote-");
+ yield* initRepoWithCommit(cwd);
+ yield* git(remote, ["init", "--bare"]);
- const before = yield* driver.statusDetailsLocal(cwd);
- assert.equal(before.hasOriginRemote, false);
+ assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false);
+ assert.equal((yield* driver.listRefs({ cwd })).hasPrimaryRemote, false);
- yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote });
+ yield* driver.ensureRemote({ cwd, preferredName: remoteName, url: remote });
- const after = yield* driver.statusDetailsLocal(cwd);
- assert.equal(after.hasOriginRemote, true);
- }).pipe(Effect.provide(layerTest)),
+ assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, true);
+ assert.equal((yield* driver.listRefs({ cwd })).hasPrimaryRemote, true);
+ }).pipe(Effect.provide(layerTest)),
);
it.effect.each([
@@ -1384,6 +1386,61 @@ it.effect("lets a worktree checkout that is still working run past five minutes"
}).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
);
+it.effect("cancels a slow submodule checkout without reporting best-effort success", () =>
+ Effect.gen(function* () {
+ const updateStarted = yield* Deferred.make();
+ const updateStopped = yield* Deferred.make();
+ const finished = yield* Ref.make>([]);
+ const claimed = yield* Ref.make(null);
+ const spawner = ChildProcessSpawner.make((command) => {
+ if (
+ ChildProcess.isStandardCommand(command) &&
+ command.args[0] === "submodule" &&
+ command.args[1] === "update"
+ ) {
+ return Effect.acquireRelease(
+ Effect.succeed(
+ ChildProcessSpawner.makeHandle({
+ ...makeSuccessfulHandle(""),
+ exitCode: Deferred.succeed(updateStarted, undefined).pipe(
+ Effect.andThen(Effect.never),
+ ),
+ }),
+ ),
+ () => Deferred.succeed(updateStopped, undefined),
+ );
+ }
+ return Effect.succeed(makeSuccessfulHandle(""));
+ });
+ const driver = yield* makeGitVcsDriverCore().pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+ const cwd = yield* makeTmpDir();
+ const path = yield* Path.Path;
+ const worktreePath = path.join(cwd, "worktree");
+ yield* writeTextFile(worktreePath, ".gitmodules", "");
+ const creation = yield* driver
+ .createWorktree(
+ { cwd, refName: "main", newRefName: "bugfix/cancel-submodules", path: worktreePath },
+ {
+ progress: {
+ onWorktreeClaimed: (value) => Ref.set(claimed, value),
+ onSubmodulesFinished: (result) => Ref.update(finished, (items) => [...items, result]),
+ },
+ },
+ )
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(updateStarted);
+ yield* TestClock.adjust("6 minutes");
+ yield* Fiber.interrupt(creation);
+ const exit = yield* Fiber.await(creation);
+ assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause));
+ assert.isTrue(yield* Deferred.isDone(updateStopped));
+ assert.equal(yield* Ref.get(claimed), worktreePath);
+ assert.isEmpty(yield* Ref.get(finished));
+ }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
+);
+
describe("rejected git refs", () => {
const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git";
const failedPush = `error: failed to push some refs to '${remoteUrl}'`;
@@ -3124,13 +3181,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n");
yield* git(cwd, ["commit", "-am", "commit edit"]);
yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n");
+ yield* writeTextFile(cwd, "new [1].txt", "literal\n");
const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), {
includeBranchChanges: true,
});
assert.deepStrictEqual(status.branchChanges, {
baseRef: "main",
- insertions: 3,
+ insertions: 4,
deletions: 0,
});
assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges);
@@ -3187,25 +3245,58 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
- const driver = yield* GitVcsDriver.GitVcsDriver;
+ const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const untrackedScanRoots: string[][] = [];
+ const spawner = ChildProcessSpawner.make((command) => {
+ if (
+ ChildProcess.isStandardCommand(command) &&
+ command.args.includes("ls-files") &&
+ command.args.includes("--others")
+ ) {
+ untrackedScanRoots.push(command.args.slice(command.args.indexOf("--") + 1));
+ }
+ return delegate.spawn(command);
+ });
+ const driver = yield* makeGitVcsDriverCore().pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
yield* git(cwd, ["branch", "-M", "main"]);
yield* git(cwd, ["checkout", "-b", "feature/many-untracked"]);
+ yield* writeTextFile(cwd, ".git/info/exclude", "ignored.txt\n");
+ yield* writeTextFile(cwd, "bulk[1]/ignored.txt", "ignored\n");
yield* Effect.forEach(
- Array.from({ length: 1_001 }, (_, index) => `bulk/${index}.txt`),
+ Array.from({ length: 1_001 }, (_, index) => `bulk[1]/${index}.txt`),
(file) => writeTextFile(cwd, file, "one\ntwo\n"),
{ concurrency: 32, discard: true },
);
- const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true });
- assert.deepStrictEqual(status.branchChanges, {
- baseRef: "main",
- insertions: 2_002,
- deletions: 0,
- });
- assert.deepStrictEqual(
- status.workingTree.files.map((file) => file.path),
- ["bulk/"],
- );
+ for (const scenario of ["normal", "all", "no", "long names"]) {
+ if (scenario === "long names") {
+ yield* Effect.forEach(
+ Array.from({ length: 50 }, (_, index) => `${"a".repeat(180)}-${index}.txt`),
+ (file) => writeTextFile(cwd, file, "long name\n"),
+ { concurrency: 32, discard: true },
+ );
+ }
+ const mode = scenario === "long names" ? "normal" : scenario;
+ yield* git(cwd, ["config", "status.showUntrackedFiles", mode]);
+ untrackedScanRoots.length = 0;
+ const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true });
+ assert.deepStrictEqual(status.branchChanges, {
+ baseRef: "main",
+ insertions: scenario === "long names" ? 2_052 : 2_002,
+ deletions: 0,
+ });
+ assert.deepStrictEqual(untrackedScanRoots, [
+ scenario === "normal" ? [":(top,literal)bulk[1]/"] : [],
+ ]);
+ if (scenario === "normal") {
+ assert.deepStrictEqual(
+ status.workingTree.files.map((file) => file.path),
+ ["bulk[1]/"],
+ );
+ }
+ }
}),
);
@@ -4129,7 +4220,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
- it.effect("checks out submodules in a new worktree", () =>
+ it.effect.each([
+ ["recursive", false],
+ ["recursive", true],
+ ["top-level", false],
+ ["top-level", true],
+ ] as const)("finishes a slow submodule checkout (%s, progress: %s)", ([submodules, progress]) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
@@ -4149,6 +4245,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
const submoduleRepo = yield* makeTmpDir("git-submodule-");
yield* initRepoWithCommit(submoduleRepo);
+ yield* writeTextFile(submoduleRepo, ".gitattributes", "* text eol=lf\n");
yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n");
yield* git(submoduleRepo, ["add", "."]);
yield* git(submoduleRepo, ["commit", "-m", "shared"]);
@@ -4157,23 +4254,80 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
const { initialBranch } = yield* initRepoWithCommit(cwd);
yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]);
yield* git(cwd, ["commit", "-m", "add submodule"]);
+ const recordedHead = yield* git(submoduleRepo, ["rev-parse", "HEAD"]);
+ yield* writeTextFile(submoduleRepo, "SHARED.md", "# newer remote default\n");
+ yield* git(submoduleRepo, ["commit", "-am", "advance remote default"]);
+
+ const updateStarted = yield* Deferred.make();
+ const releaseUpdate = yield* Deferred.make();
+ const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const spawner = ChildProcessSpawner.make((command) =>
+ Effect.gen(function* () {
+ if (
+ ChildProcess.isStandardCommand(command) &&
+ command.args[0] === "submodule" &&
+ command.args[1] === "update"
+ ) {
+ yield* Deferred.succeed(updateStarted, undefined);
+ yield* Deferred.await(releaseUpdate);
+ }
+ return yield* delegate.spawn(command);
+ }),
+ );
+ const driver = yield* makeGitVcsDriverCore().pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provide(layerServerConfig),
+ );
+ const finished = yield* Ref.make>([]);
+ const lines = yield* Ref.make>([]);
const worktreePath = pathService.join(
yield* makeTmpDir("git-worktrees-"),
"submodule-worktree",
);
- const driver = yield* GitVcsDriver.GitVcsDriver;
- yield* driver.createWorktree({
- cwd,
- path: worktreePath,
- refName: initialBranch,
- newRefName: "feature/submodules",
- });
+ const creation = yield* driver
+ .createWorktree(
+ {
+ cwd,
+ path: worktreePath,
+ refName: initialBranch,
+ newRefName: "feature/submodules",
+ },
+ {
+ submodules,
+ progress: {
+ onSubmodulesFinished: (result) =>
+ Ref.update(finished, (items) => [...items, result]),
+ ...(progress
+ ? {
+ onSubmoduleLine: (line: string) =>
+ Ref.update(lines, (items) => [...items, line]),
+ }
+ : {}),
+ },
+ },
+ )
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(updateStarted);
+ yield* TestClock.adjust("6 minutes");
+ yield* Deferred.succeed(releaseUpdate, undefined);
+ yield* Fiber.join(creation);
+ assert.deepEqual(yield* Ref.get(finished), [{ ok: true, detail: null }]);
assert.equal(
yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")),
true,
);
+ assert.equal(
+ yield* git(pathService.join(worktreePath, "shared"), ["rev-parse", "HEAD"]),
+ recordedHead,
+ );
+ assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), "");
+ assert.equal(
+ yield* git(pathService.join(worktreePath, "shared"), ["status", "--porcelain"]),
+ "",
+ );
+ if (progress) assert.isNotEmpty(yield* Ref.get(lines));
}),
);
@@ -4190,6 +4344,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
'[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n',
);
yield* git(cwd, ["add", "."]);
+ const gitlink = yield* git(cwd, ["rev-parse", "HEAD"]);
+ yield* git(cwd, ["update-index", "--add", "--cacheinfo", `160000,${gitlink},missing`]);
yield* git(cwd, ["commit", "-m", "add unreachable submodule"]);
const worktreePath = pathService.join(
@@ -4197,15 +4353,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
"broken-submodule-worktree",
);
const driver = yield* GitVcsDriver.GitVcsDriver;
- const created = yield* driver.createWorktree({
- cwd,
- path: worktreePath,
- refName: initialBranch,
- newRefName: "feature/broken-submodules",
- });
+ const finished = yield* Ref.make>([]);
+ const created = yield* driver.createWorktree(
+ {
+ cwd,
+ path: worktreePath,
+ refName: initialBranch,
+ newRefName: "feature/broken-submodules",
+ },
+ {
+ progress: {
+ onSubmodulesFinished: (result) => Ref.update(finished, (items) => [...items, result]),
+ },
+ },
+ );
assert.equal(created.worktree.path, worktreePath);
assert.equal(yield* fileSystem.exists(worktreePath), true);
+ const results = yield* Ref.get(finished);
+ assert.lengthOf(results, 1);
+ assert.isFalse(results[0]!.ok);
+ assert.include(results[0]!.detail, "GitVcsDriver.createWorktree.updateSubmodules");
}),
);
@@ -4939,6 +5107,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
+ it.effect("stops a selected-file commit when another process holds the index lock", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ yield* initRepoWithCommit(cwd);
+ const head = yield* git(cwd, ["rev-parse", "HEAD"]);
+ yield* writeTextFile(cwd, "a.txt", "a\n");
+ yield* writeTextFile(cwd, "staged.txt", "staged\n");
+ yield* git(cwd, ["add", "staged.txt"]);
+ const lockPath = (yield* Path.Path).join(cwd, ".git", "index.lock");
+ yield* (yield* FileSystem.FileSystem).writeFileString(lockPath, "");
+ const { driver } = yield* makeDriverReleasingLock(
+ (args) => args.includes("reset"),
+ lockPath,
+ );
+
+ const error = yield* driver
+ .commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } })
+ .pipe(Effect.flip);
+
+ assert.equal(error.operation, "GitVcsDriver.commit.reset");
+ assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head);
+ assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "staged.txt");
+ }),
+ );
+
it.effect.each([
{ location: "root", ref: "refs/heads/MERGE_HEAD" },
{ location: "nested", ref: "refs/heads/MERGE_HEAD" },
@@ -5431,6 +5624,55 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
+ it.effect("fetches a worktree base without downloading tags", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const remote = yield* makeTmpDir("git-remote-");
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["remote", "add", "origin", remote]);
+ yield* git(cwd, ["push", "-u", "origin", initialBranch]);
+ const localHead = yield* git(cwd, ["rev-parse", "HEAD"]);
+ yield* git(cwd, ["commit", "--allow-empty", "-m", "tagged release"]);
+ const remoteHead = yield* git(cwd, ["rev-parse", "HEAD"]);
+ yield* git(cwd, ["tag", "v1"]);
+ yield* git(cwd, ["push", "origin", initialBranch, "v1"]);
+ yield* git(cwd, ["tag", "-d", "v1"]);
+ yield* git(cwd, ["reset", "--hard", localHead]);
+ yield* git(cwd, ["update-ref", "-d", `refs/remotes/origin/${initialBranch}`]);
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+
+ assert.deepEqual(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: initialBranch }), {
+ commitSha: remoteHead,
+ remoteRefName: `origin/${initialBranch}`,
+ });
+ assert.equal(yield* git(cwd, ["tag", "--list", "v1"]), "");
+ }),
+ );
+
+ it.effect("fetches the remote a caller already resolved for the worktree base", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const remote = yield* makeTmpDir("git-remote-");
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["remote", "add", "origin", remote]);
+ yield* git(cwd, ["push", "origin", initialBranch]);
+ const head = yield* git(cwd, ["rev-parse", "HEAD"]);
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+
+ assert.isNull(yield* driver.fetchWorktreeBaseCommit({ cwd, refName: "unpublished" }));
+ assert.deepEqual(
+ yield* driver.fetchWorktreeBaseCommit({
+ cwd,
+ refName: "unpublished",
+ remote: { remoteName: "origin", branchName: initialBranch },
+ }),
+ { commitSha: head, remoteRefName: `origin/${initialBranch}` },
+ );
+ }),
+ );
+
it.effect("fails instead of falling back when the worktree base remote is unreachable", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index fce1566f428c3..1cf5ca52effee 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -1538,18 +1538,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}),
},
);
- const originExistsCache = yield* Cache.makeWith(
+ const primaryRemoteExistsCache = yield* Cache.makeWith(
(gitCommonDir: string) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fetchCwd =
path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
return yield* executeGit(
- "GitVcsDriver.statusDetails.originExists",
+ "GitVcsDriver.statusDetails.primaryRemoteExists",
fetchCwd,
- ["--git-dir", gitCommonDir, "remote", "get-url", "origin"],
+ ["--git-dir", gitCommonDir, "remote"],
{ allowNonZeroExit: true },
- ).pipe(Effect.map((result) => result.exitCode === 0));
+ ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0));
}),
{
capacity: 2_048,
@@ -1569,7 +1569,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
: gitCommonDir;
const cacheKey = commonDir ?? normalizeRepositoryPathsCacheKey(cwd);
yield* Cache.invalidate(defaultBranchCache, cacheKey);
- yield* Cache.invalidate(originExistsCache, cacheKey);
+ yield* Cache.invalidate(primaryRemoteExistsCache, cacheKey);
},
);
@@ -2183,8 +2183,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null))
: resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)),
statusCacheKey
- ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false))
- : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)),
+ ? Cache.get(primaryRemoteExistsCache, statusCacheKey).pipe(
+ Effect.orElseSucceed(() => false),
+ )
+ : listRemoteNames(cwd).pipe(
+ Effect.map((remoteNames) => remoteNames.length > 0),
+ Effect.orElseSucceed(() => false),
+ ),
],
{ concurrency: "unbounded" },
);
@@ -2219,6 +2224,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
let aheadOfDefaultCount = 0;
let hasWorkingTreeChanges = false;
const changedFilesWithoutNumstat = new Set();
+ const untrackedRoots: string[] = [];
const statusRecords = statusStdout.split("\0");
for (let index = 0; index < statusRecords.length; index++) {
@@ -2243,7 +2249,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
if (line.length > 0 && !line.startsWith("#")) {
hasWorkingTreeChanges = true;
const pathValue = parsePorcelainPath(line);
- if (pathValue) changedFilesWithoutNumstat.add(pathValue);
+ if (pathValue) {
+ changedFilesWithoutNumstat.add(pathValue);
+ if (line.startsWith("? ")) untrackedRoots.push(pathValue);
+ }
// A rename record is followed by its original path as a separate record. Listing it
// keeps the deletion selectable when committing only some files.
if (line.startsWith("2 ")) {
@@ -2269,6 +2278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
options?.includeBranchChanges
? readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName, {
knownOriginDefaultBranch: defaultBranch,
+ untrackedRoots,
}).pipe(Effect.orElseSucceed(() => undefined))
: Effect.undefined,
],
@@ -2293,13 +2303,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
let insertions = 0;
let deletions = 0;
- const files = Array.from(fileStatMap.entries())
- .map(([filePath, stat]) => {
- insertions += stat.insertions;
- deletions += stat.deletions;
- return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
- })
- .toSorted((a, b) => a.path.localeCompare(b.path));
+ const files = Array.from(fileStatMap.entries()).map(([filePath, stat]) => {
+ insertions += stat.insertions;
+ deletions += stat.deletions;
+ return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
+ });
for (const filePath of changedFilesWithoutNumstat) {
if (fileStatMap.has(filePath)) continue;
@@ -2429,9 +2437,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
"Cannot commit selected files during a merge. Commit all files to finish the merge.",
});
}
- yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe(
- Effect.catchTags({ GitCommandError: () => Effect.void }),
- );
+ yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]);
}
yield* runGit(
"GitVcsDriver.commit.addSelected",
@@ -3120,7 +3126,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* (
cwd: string,
branch: string | null,
- options?: { readonly knownOriginDefaultBranch: string | null },
+ options?: {
+ readonly knownOriginDefaultBranch: string | null;
+ readonly untrackedRoots: ReadonlyArray;
+ },
) {
const { baseRef, mergeBase } = yield* resolveReviewMergeBase(
cwd,
@@ -3128,7 +3137,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
undefined,
options?.knownOriginDefaultBranch ?? null,
);
- const untracked = yield* prepareUntrackedReviewIndex(cwd, []);
+ const roots = options?.untrackedRoots ?? [];
+ // Bound pathspec matching work and stay below Windows' command-line limit.
+ const pathspecs =
+ roots.length <= 128 && roots.reduce((length, root) => length + root.length + 16, 0) < 8_000
+ ? roots.map((root) => `:(top,literal)${root}`)
+ : [];
+ // Empty status can mean untracked files are hidden; retain the full scan.
+ const untracked = yield* prepareUntrackedReviewIndex(cwd, pathspecs);
if (untracked === null) {
return yield* new GitCommandError({
operation: "GitVcsDriver.readBranchChangeTotals",
@@ -3929,7 +3945,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
return {
localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
- hasPrimaryRemote: remoteNames.includes("origin"),
+ hasPrimaryRemote: remoteNames.length > 0,
} satisfies GitRefsSnapshot;
});
@@ -4197,7 +4213,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const progress = options?.progress;
const onCheckoutProgress = progress?.onCheckoutProgress;
- const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0";
+ // Both reads leave the repository alone, so they share one round trip before the checkout.
+ const [configuredCheckoutWorkers, base] = yield* Effect.all(
+ [
+ readConfigValue(input.cwd, "checkout.workers"),
+ input.newRefName && input.baseRefName
+ ? resolveBranchBase(input.cwd, input.baseRefName)
+ : Effect.succeed(null),
+ ],
+ { concurrency: "unbounded" },
+ );
+ const checkoutWorkers = configuredCheckoutWorkers ?? "0";
yield* executeGitWithStableDiagnostics(
"GitVcsDriver.createWorktree",
input.cwd,
@@ -4265,17 +4291,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
submoduleMode.value === "recursive"
? ["submodule", "update", "--init", "--recursive"]
: ["submodule", "update", "--init"],
- onSubmoduleLine
- ? {
- env: { LC_ALL: "C" },
- progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine },
- }
- : {},
+ {
+ // Cloning scales with remote size; let it finish or be cancelled, like worktree add.
+ timeoutMs: null,
+ ...(onSubmoduleLine
+ ? {
+ env: { LC_ALL: "C" },
+ progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine },
+ }
+ : {}),
+ },
),
),
Effect.matchEffect({
onFailure: (cause) =>
- Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", {
+ Effect.logWarning("worktree submodule checkout failed; submodules may be incomplete", {
worktreePath,
cause,
}).pipe(
@@ -4294,32 +4324,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
const newRefName = input.newRefName;
- if (newRefName && input.baseRefName) {
- const base = yield* resolveBranchBase(input.cwd, input.baseRefName);
- if (base) {
- yield* Effect.gen(function* () {
+ if (newRefName && base) {
+ yield* Effect.gen(function* () {
+ yield* writeConfigValue({
+ cwd: input.cwd,
+ key: branchBaseConfigKey(newRefName),
+ value: base.branchName,
+ });
+ if (base.baseRef.startsWith("refs/remotes/")) {
+ // The base key names a branch on the host; this one keeps the remote it came from.
yield* writeConfigValue({
cwd: input.cwd,
- key: branchBaseConfigKey(newRefName),
- value: base.branchName,
+ key: `branch.${newRefName}.t3-merge-base-ref`,
+ value: base.baseRef,
});
- if (base.baseRef.startsWith("refs/remotes/")) {
- // The base key names a branch on the host; this one keeps the remote it came from.
- yield* writeConfigValue({
- cwd: input.cwd,
- key: `branch.${input.newRefName}.t3-merge-base-ref`,
- value: base.baseRef,
- });
- }
- }).pipe(
- Effect.catchTags({
- GitCommandError: () =>
- Effect.logWarning(
- "worktree base-ref configuration failed; using base-branch fallback",
- ),
- }),
- );
- }
+ }
+ }).pipe(
+ Effect.catchTags({
+ GitCommandError: () =>
+ Effect.logWarning("worktree base-ref configuration failed; using base-branch fallback"),
+ }),
+ );
}
return {
@@ -4485,7 +4510,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const branch =
parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? input.refName;
const scopedArgs = [
- ...args,
+ "fetch",
+ "--quiet",
+ "--no-tags",
+ "--",
+ input.remoteName,
`+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`,
];
const result = yield* runFetch(scopedArgs);
@@ -4565,7 +4594,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const fetchWorktreeBaseCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchWorktreeBaseCommit"] =
Effect.fn("fetchWorktreeBaseCommit")(function* (input) {
- const target = yield* resolveWorktreeBaseRemote(input);
+ const target = input.remote ?? (yield* resolveWorktreeBaseRemote(input));
if (target === null) return null;
const { remoteName, branchName } = target;
const remoteRefName = `${remoteName}/${branchName}`;
diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts
index 0f252d600a5ba..786d55dbe0555 100644
--- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts
+++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts
@@ -23,7 +23,23 @@ import { vi } from "vite-plus/test";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal();
- return { ...actual, realpath: vi.fn(actual.realpath) };
+ return { ...actual, realpath: vi.fn(actual.realpath), open: vi.fn(actual.open) };
+});
+
+const injectShortReads = Effect.fn("injectShortReads")(function* (target: string) {
+ const native = yield* Effect.promise(() => vi.importActual("node:fs/promises"));
+ const open = vi.mocked(NodeFSP.open).mockImplementation(async (path, flags, mode) => {
+ const handle = await native.open(path, flags, mode);
+ if (path === target) {
+ const read = handle.read.bind(handle);
+ Object.defineProperty(handle, "read", {
+ value: (buffer: Uint8Array, offset: number, length: number, position: number) =>
+ read(buffer, offset, Math.min(length, 2), position),
+ });
+ }
+ return handle;
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open)));
});
const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-";
@@ -162,6 +178,57 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
});
describe("readFile", () => {
+ it.effect("reads UTF-8 files relative to the workspace root", () =>
+ Effect.gen(function* () {
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
+ const cwd = yield* makeTempDir;
+ yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");
+
+ const result = yield* workspaceFileSystem.readFile({
+ cwd,
+ relativePath: "src/index.ts",
+ });
+
+ expect(result).toEqual({
+ relativePath: "src/index.ts",
+ contents: "export const answer = 42;\n",
+ byteLength: 26,
+ truncated: false,
+ });
+ }),
+ );
+
+ it.effect("finishes short reads before decoding UTF-8 text", () =>
+ Effect.gen(function* () {
+ const files = yield* WorkspaceFileSystem.WorkspaceFileSystem;
+ const fs = yield* FileSystem.FileSystem;
+ const cwd = yield* makeTempDir;
+ const contents = "A café 🐈\n";
+ yield* writeTextFile(cwd, "short.txt", contents);
+ const target = yield* fs.realPath(`${cwd}/short.txt`);
+ yield* injectShortReads(target);
+ expect(yield* files.readFile({ cwd, relativePath: "short.txt" })).toEqual({
+ relativePath: "short.txt",
+ contents,
+ byteLength: Buffer.byteLength(contents),
+ truncated: false,
+ });
+ }),
+ );
+
+ it.effect("rejects binary bytes delivered after an initial short read", () =>
+ Effect.gen(function* () {
+ const files = yield* WorkspaceFileSystem.WorkspaceFileSystem;
+ const fs = yield* FileSystem.FileSystem;
+ const cwd = yield* makeTempDir;
+ yield* writeTextFile(cwd, "short.txt", "ab\0cd");
+ const target = yield* fs.realPath(`${cwd}/short.txt`);
+ yield* injectShortReads(target);
+ const error = yield* files.readFile({ cwd, relativePath: "short.txt" }).pipe(Effect.flip);
+ expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
+ }),
+ );
+
it.effect("reads host files outside the workspace root by absolute path", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts
index e611d2bd71a3d..489fedc2dce09 100644
--- a/apps/server/src/workspace/WorkspaceFileSystem.ts
+++ b/apps/server/src/workspace/WorkspaceFileSystem.ts
@@ -405,19 +405,24 @@ export const make = Effect.gen(function* () {
const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
const buffer = Buffer.alloc(bytesToRead);
- const { bytesRead } = yield* Effect.tryPromise({
- try: () => handle.read(buffer, 0, bytesToRead, 0),
- catch: (cause) =>
- new WorkspaceFileSystemOperationError({
- workspaceRoot: input.cwd,
- relativePath: input.relativePath,
- resolvedPath: realTargetPath,
- operationPath: realTargetPath,
- operation: "read",
- cause,
- }),
- });
- const fileBytes = buffer.subarray(0, bytesRead);
+ let offset = 0;
+ while (offset < bytesToRead) {
+ const { bytesRead } = yield* Effect.tryPromise({
+ try: () => handle.read(buffer, offset, bytesToRead - offset, offset),
+ catch: (cause) =>
+ new WorkspaceFileSystemOperationError({
+ workspaceRoot: input.cwd,
+ relativePath: input.relativePath,
+ resolvedPath: realTargetPath,
+ operationPath: realTargetPath,
+ operation: "read",
+ cause,
+ }),
+ });
+ if (bytesRead <= 0) break;
+ offset += bytesRead;
+ }
+ const fileBytes = buffer.subarray(0, offset);
if (fileBytes.includes(0)) {
return yield* new WorkspaceBinaryFileError({
workspaceRoot: input.cwd,
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index f23da563a0807..375fded2db8e7 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -429,7 +429,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs
Stream.map((stored) => ({
kind: "event" as const,
sequence: stored.sequence,
- event: projectDomainEventForWire(stored.event),
+ event: stored.event,
})),
coalesceThreadLiveStream,
Stream.mapError(
@@ -1287,6 +1287,7 @@ const layerWsRpc = () =>
threadManagement.forceStopThread(input),
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) =>
readWorkflowScript({ scriptPath: input.scriptPath }),
+ // Coder: no `getNativeChildWork`; it reads only Antigravity's native child work (#28629).
[ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) =>
threadManagement.getTurnItem(input).pipe(
Effect.mapError(
@@ -1581,14 +1582,12 @@ const layerWsRpc = () =>
[WS_METHODS.serverUpsertKeybinding]:
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* (rule) {
- const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
- return { keybindings: keybindingsConfig, issues: [] };
+ return yield* keybindings.upsertKeybindingRule(rule);
}),
[WS_METHODS.serverRemoveKeybinding]:
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* (rule) {
- const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule);
- return { keybindings: keybindingsConfig, issues: [] };
+ return yield* keybindings.removeKeybindingRule(rule);
}),
[WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow,
[WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports,
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index d8687a4213251..2aaf728b43608 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -12,7 +12,6 @@ import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
import type { ContextMenuItem } from "../localApiTypes";
import { GitBranchIcon, PencilIcon } from "lucide-react";
import {
- useDeferredValue,
useEffect,
useImperativeHandle,
useOptimistic,
@@ -229,7 +228,6 @@ export function BranchToolbarBranchSelector({
const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
const [branchQuery, setBranchQuery] = useState("");
- const deferredBranchQuery = useDeferredValue(branchQuery);
const branchStatusQuery = useEnvironmentQuery(
branchCwd === null
@@ -239,18 +237,17 @@ export function BranchToolbarBranchSelector({
input: { cwd: branchCwd },
}),
);
+ const trimmedBranchQuery = branchQuery.trim();
const branchRefState = usePaginatedBranches({
environmentId,
cwd: branchCwd,
// Substring search needs sanitized names to detect new-branch collisions; refs cannot contain ASCII
// spaces.
- query: sanitizeNewRefName(deferredBranchQuery.trim()),
+ query: sanitizeNewRefName(trimmedBranchQuery),
});
- const trimmedBranchQuery = branchQuery.trim();
- const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
const refs = branchRefState.refs;
const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const));
- const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
+ const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
const prReference = parsePullRequestReference(trimmedBranchQuery);
const isSelectingWorktreeBase =
effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
@@ -335,12 +332,12 @@ export function BranchToolbarBranchSelector({
...(pickerCreateBranchItemValue ? [pickerCreateBranchItemValue] : []),
];
const filteredBranchPickerItems =
- normalizedDeferredBranchQuery.length === 0
+ normalizedBranchQuery.length === 0
? branchPickerItems
: branchPickerItems.filter((itemValue) =>
shouldIncludeBranchPickerItem({
itemValue,
- normalizedQuery: normalizedDeferredBranchQuery,
+ normalizedQuery: normalizedBranchQuery,
createBranchItemValue: pickerCreateBranchItemValue,
checkoutPullRequestItemValue,
}),
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 2fee4560efcfa..e0ce3f24a6273 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -1171,6 +1171,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
workspaceMutationId,
renderContextReference,
fileLinkChip,
+ resolvedTheme,
} = use(ChatMarkdownRendererContext);
const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null;
@@ -1182,10 +1183,13 @@ const CHAT_MARKDOWN_COMPONENTS = {
{label}
);
}
- const localSrc = node?.properties?.dataLocalSrc;
+ // A ``'s source for this theme, resolved like any other image.
+ const themeSrc = node?.properties?.[resolvedTheme === "dark" ? "dataDarkSrc" : "dataLightSrc"];
+ const localSrc = typeof themeSrc === "string" ? undefined : node?.properties?.dataLocalSrc;
const markdownTitle = node?.properties?.dataMarkdownTitle;
const standalone = node?.properties?.dataStandalone === true;
- const authoredSrc = typeof localSrc === "string" ? localSrc : src;
+ const authoredSrc =
+ typeof themeSrc === "string" ? themeSrc : typeof localSrc === "string" ? localSrc : src;
const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
const srcString =
typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : "";
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 860e5c5b74ee9..97daec439bb69 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -2,6 +2,7 @@ import {
boundAttachmentIds,
buildExpiredTerminalContextToastCopy,
planComposerTurn,
+ restorePlanFollowUpComposer,
snapshotComposerSend,
withMessageContext,
deriveAvailableRevertTurnCounts,
@@ -1045,6 +1046,60 @@ describe("planComposerTurn", () => {
});
});
+describe("restorePlanFollowUpComposer", () => {
+ // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on
+ // screen.
+ it.each([
+ {
+ name: "puts the follow-up back while its thread is open",
+ before: { draft: "", refs: "", cursor: null },
+ routeIsCurrent: true,
+ after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" },
+ },
+ {
+ name: "keeps a draft typed while the follow-up was in flight",
+ before: { draft: "Next idea", refs: "Next idea", cursor: null },
+ routeIsCurrent: true,
+ after: { draft: "Next idea", refs: "Next idea", cursor: null },
+ },
+ {
+ name: "leaves another thread's composer alone after navigating away",
+ before: { draft: "", refs: "Other thread", cursor: null },
+ routeIsCurrent: false,
+ after: { draft: "Tighten step 2", refs: "Other thread", cursor: null },
+ },
+ ])("$name", ({ before, routeIsCurrent, after }) => {
+ const composer: { draft: string; refs: string; cursor: string | null } = { ...before };
+ restorePlanFollowUpComposer({
+ snapshot: {
+ prompt: "Tighten step 2",
+ environmentContexts: [],
+ terminalContexts: [],
+ reviewComments: [],
+ // Coder: no preview annotations in the plan follow-up snapshot.
+ threadContexts: [],
+ },
+ draftHasUserContent: composer.draft.trim().length > 0,
+ routeIsCurrent,
+ writeComposerRefs: (snapshot) => {
+ composer.refs = snapshot.prompt;
+ },
+ writePrompt: (prompt) => {
+ composer.draft = prompt;
+ },
+ writeTerminalContexts: () => undefined,
+ writeReviewComments: () => undefined,
+ writeThreadContexts: () => undefined,
+ writeEnvironmentContexts: () => undefined,
+ resetCursor: ({ prompt }) => {
+ composer.cursor = prompt;
+ },
+ });
+
+ expect(composer).toEqual(after);
+ });
+});
+
describe("withMessageContext", () => {
const context = { records: [] } as unknown as NonNullable<
Parameters[0]["context"]
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 79be5accb32cf..8896abc1ad1a6 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -1099,8 +1099,13 @@ export interface PlanFollowUpComposerSnapshot {
readonly environmentContexts: ReadonlyArray;
}
+// A draft typed while the send was in flight is newer and stays. Composer refs and the cursor
+// belong to the thread on screen, so they follow only while the follow-up's route is current.
export function restorePlanFollowUpComposer(input: {
readonly snapshot: PlanFollowUpComposerSnapshot;
+ readonly draftHasUserContent: boolean;
+ readonly routeIsCurrent: boolean;
+ readonly writeComposerRefs: (snapshot: PlanFollowUpComposerSnapshot) => void;
readonly writePrompt: (prompt: string) => void;
readonly writeTerminalContexts: (contexts: ReadonlyArray) => void;
readonly writeReviewComments: (comments: ReadonlyArray) => void;
@@ -1112,11 +1117,14 @@ export function restorePlanFollowUpComposer(input: {
detectTrigger: boolean;
}) => void;
}): void {
+ if (input.draftHasUserContent) return;
+ if (input.routeIsCurrent) input.writeComposerRefs(input.snapshot);
input.writePrompt(input.snapshot.prompt);
input.writeTerminalContexts(input.snapshot.terminalContexts);
input.writeReviewComments(input.snapshot.reviewComments);
input.writeThreadContexts(input.snapshot.threadContexts);
input.writeEnvironmentContexts(input.snapshot.environmentContexts);
+ if (!input.routeIsCurrent) return;
input.resetCursor({
cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length),
prompt: input.snapshot.prompt,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 391b28ceb2bee..13e7efbef3888 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -331,6 +331,7 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat
const EMPTY_ANCHORED_TIMELINE_MESSAGES: ReadonlyArray = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = [];
+const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = [];
const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set();
@@ -766,20 +767,18 @@ export default function ChatView(props: ChatViewProps) {
const sideChatParent = useQuietThreadShell(sideChatParentRef);
const parentThreadLink = (() => {
if (sideChatParentRef !== null) {
+ const title = sideChatParent?.title ?? "Parent thread";
+ // Embedded, the parent is the view around it.
+ if (embedded) return { title, sideChat: true };
return {
- title: sideChatParent?.title ?? "Parent thread",
+ title,
sideChat: true,
- // Embedded, the parent is the view around it.
- ...(embedded
- ? {}
- : {
- open: (): void => {
- void navigate({
- to: "/$environmentId/$threadId",
- params: buildThreadRouteParams(sideChatParentRef),
- });
- },
- }),
+ open: (): void => {
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(sideChatParentRef),
+ });
+ },
};
}
if (parentSubagentThreadRef !== null) {
@@ -3158,6 +3157,7 @@ export default function ChatView(props: ChatViewProps) {
effortLabel={providerSubagentEffortLabel}
status={providerSubagentStatus}
onOpenParent={parentThreadLink?.open ?? null}
+ // Coder: Antigravity is not carried, so no native child work dialog (#28629).
/>
) : null}
{!composerMounted ? null : (
@@ -3183,7 +3183,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadId={activeThreadId}
activeThread={activeThread}
activeThreadShell={activeThreadShell}
- promptHistoryMessages={timelineMessages}
+ promptHistoryEntries={timelineEntries}
forceExpandedOnMobile={
forceExpandedMobileComposer && isDraftHeroState
}
@@ -3218,7 +3218,11 @@ export default function ChatView(props: ChatViewProps) {
: projectCloneSendBlockReason
}
isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
- bannerItems={isDraftHeroState ? [] : composerBannerItems}
+ bannerItems={
+ isDraftHeroState || composerBannerItems.length === 0
+ ? EMPTY_COMPOSER_BANNER_ITEMS
+ : composerBannerItems
+ }
resumeCompactionTokens={resumeCompactionTokens}
keepFullHistory={keepFullHistory}
onToggleKeepFullHistory={toggleKeepFullHistoryStable}
@@ -3251,7 +3255,9 @@ export default function ChatView(props: ChatViewProps) {
}
activeThreadModelSelection={activeThread?.modelSelection}
activeProjectId={activeProject?.id ?? null}
- activeContextWindow={activeContextWindow}
+ contextWindowEntries={serverVisibleTurnItems}
+ contextWindowLiveUsage={activeThreadLiveTokenUsage}
+ contextWindowProviderThread={activeProviderThread}
compactThreadUnavailable={compactThreadUnavailable}
compactDisabled={compactDisabled}
compactDisabledReason={compactDisabledReason}
diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx
index 765de6d0b99a4..287d684ac0957 100644
--- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx
+++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx
@@ -29,6 +29,7 @@ vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions:
vi.mock("../hooks/useSettings", async (original) => ({
...(await original()),
useClientSettings: () => DEFAULT_CLIENT_SETTINGS,
+ useUpdateEnvironmentSettings: () => () => undefined,
}));
vi.mock("../hooks/useScratchProject", () => ({
useScratchProject: () => ({ scratchEnvironmentId: () => null }),
@@ -74,6 +75,10 @@ vi.mock("../state/entities", async (original) => ({
...(await original()),
useProjects: () => [],
useThreadShells: () => [],
+ useVisibleThreadShells: () => [],
+ useHiddenThreadKeys: () => new Set(),
+ useAnyEnvironmentHiding: () => false,
+ useEnvironmentThreadRefs: () => [],
useServerConfigs: () => new Map(),
waitForProject: async () => undefined,
}));
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index c70bc98eca676..d10ea09fb42ec 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -16,6 +16,7 @@ import {
getNewProjectPublishTarget,
getNewProjectPublishTargets,
getNewProjectRepository,
+ githubIncompleteOwnerRepositoryMessage,
normalizePastedCloneUrl,
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
@@ -105,7 +106,16 @@ import { useNewProject } from "../hooks/useNewProject";
import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen";
import { reportProjectFailure, useProjectPin } from "../hooks/useProjectGroupUpdate";
import { useEnvironments } from "../state/environments";
-import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
+import {
+ useAnyEnvironmentHiding,
+ useHiddenThreadKeys,
+ useEnvironmentThreadRefs,
+ useProjects,
+ useServerConfigs,
+ useVisibleThreadShells,
+ waitForProject,
+} from "../state/entities";
+import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads";
import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
import {
appendBrowsePathSegment,
@@ -119,6 +129,7 @@ import {
isUnsupportedWindowsProjectPath,
resolveProjectPathForDispatch,
} from "@t3tools/client-runtime/state/projects";
+import { resolvePastedPath } from "@t3tools/shared/path";
import {
dispatchCommandPalette,
getCommandPaletteReturnFocus,
@@ -367,6 +378,11 @@ function OpenCommandPaletteDialog(props: {
},
);
const linkedThreadSearch = initialSearch.linkedThreadSearch;
+ // Rows were captured when the palette opened; drop any hidden since then.
+ const hiddenKeys = useHiddenThreadKeys();
+ const linkedEnvironmentId = linkedThreadSearch?.linkedThreads?.environmentId ?? null;
+ const hidingAnywhere = useAnyEnvironmentHiding();
+ const linkedThreadRefs = useEnvironmentThreadRefs(linkedEnvironmentId);
const [searchState, dispatchSearch] = useReducer(reduceCommandPaletteSearchState, null, () => {
const view = props.initialContext ? null : openIntentView(props.openIntent);
return view
@@ -441,7 +457,7 @@ function OpenCommandPaletteDialog(props: {
if (activeThreadReferenceCopyTarget === null) return;
await copyThreadReference(activeThreadReferenceCopyTarget);
};
- const threads = useThreadShells();
+ const threads = useVisibleThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems();
const currentView = viewStack.at(-1)?.view ?? null;
@@ -583,11 +599,9 @@ function OpenCommandPaletteDialog(props: {
addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl,
)
: "";
- const browsePath = getFilesystemBrowsePath(
- query,
- browseEnvironmentPlatform,
- browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null,
- );
+ const canBrowsePaths =
+ browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null;
+ const browsePath = getFilesystemBrowsePath(query, browseEnvironmentPlatform, canBrowsePaths);
const isBrowsing = browsePath.isBrowsing;
const paletteMode = getCommandPaletteMode({ currentView, isBrowsing });
const getAddProjectInitialQueryForEnvironment = (environmentId: EnvironmentId | null): string => {
@@ -849,7 +863,7 @@ function OpenCommandPaletteDialog(props: {
function handleQueryChange(nextQuery: string): void {
browseNavigation.invalidate();
clearTypedHighlight();
- setQuery(nextQuery);
+ setQuery(canBrowsePaths ? resolvePastedPath(query, nextQuery) : nextQuery);
if (nextQuery === "" && currentView?.initialQuery) {
popView();
}
@@ -1663,6 +1677,13 @@ function OpenCommandPaletteDialog(props: {
isLinkedThreadSearch && linkedThreadSearch?.linkedThreads
? buildLinkedThreadActionItems({
...linkedThreadSearch.linkedThreads,
+ threads: visibleLinkedThreads(
+ linkedThreadSearch.linkedThreads.threads,
+ linkedThreadSearch.linkedThreads.environmentId,
+ linkedThreadRefs,
+ hiddenKeys,
+ hidingAnywhere,
+ ),
query: linkedThreadSearch.query,
icon: ,
runThread: async (thread) => {
@@ -1886,6 +1907,21 @@ function OpenCommandPaletteDialog(props: {
return;
}
+ const incompleteOwnerMessage = githubIncompleteOwnerRepositoryMessage(
+ addProjectCloneFlow.source,
+ rawRepository,
+ );
+ if (incompleteOwnerMessage) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Repository name incomplete",
+ description: incompleteOwnerMessage,
+ }),
+ );
+ return;
+ }
+
const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source);
if (!provider) {
const destinationPath = getCloneDestinationPath(
diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts
index 1179958500fed..96452a7a35983 100644
--- a/apps/web/src/components/CommandPaletteLookups.ts
+++ b/apps/web/src/components/CommandPaletteLookups.ts
@@ -6,7 +6,7 @@ import {
import { useClientSettings } from "../hooks/useSettings";
import { getProjectOrderKey } from "@t3tools/client-runtime/state/project-grouping";
-import { useProjects, useThreadShells } from "../state/entities";
+import { useProjects, useVisibleThreadShells } from "../state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
@@ -55,7 +55,7 @@ export const selectCommandPaletteClientSettings = (settings: ClientSettings) =>
export function useSortedProjectGroups(environmentLabelById: ReadonlyMap) {
const projects = useProjects();
- const threads = useThreadShells();
+ const threads = useVisibleThreadShells();
const projectOrder = useUiStateStore((store) => store.projectOrder);
const clientSettings = useClientSettings(selectCommandPaletteClientSettings);
const primaryEnvironmentId = usePrimaryEnvironmentId();
diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx
index d106b4d1069da..ddccc1c3a54e1 100644
--- a/apps/web/src/components/CommandPaletteThreadItems.tsx
+++ b/apps/web/src/components/CommandPaletteThreadItems.tsx
@@ -23,7 +23,7 @@ import {
deriveProviderInstanceEntries,
type ProviderInstanceEntry,
} from "../providerInstances";
-import { useThreadShells } from "../state/entities";
+import { useVisibleThreadShells } from "../state/entities";
import {
useEnvironmentProviders,
useEnvironments,
@@ -99,7 +99,7 @@ export function useCommandPaletteThreadItems(input: {
}) {
const { activeThread, threadSearchQuery } = input;
const navigate = useNavigate();
- const threads = useThreadShells();
+ const threads = useVisibleThreadShells();
const { projectEnvironmentLocationById } = useEnvironmentLookups();
const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId();
const messageSearch = useConnectedThreadSearch(threadSearchQuery);
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index fe78eaacbe850..3df1e47613370 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1018,7 +1018,7 @@ function ComposerPromptEditorTiptapInner(
const handleEditorChange = (updated: TiptapEditor) => {
const map = serializeEditorDoc(updated.state.doc);
- const contextIds = literalText ? [] : map.contextIds;
+ const contextIds = literalText ? [] : [...map.contextIds];
const { from, to } = updated.state.selection;
const fromFlat = pmToFlat(map, from);
const toFlat = pmToFlat(map, to);
@@ -1473,7 +1473,7 @@ function ComposerPromptEditorTiptapInner(
value: map.value,
cursor: collapseExpandedComposerCursor(map.value, expandedCursor, literalText),
expandedCursor,
- contextIds: literalText ? [] : map.contextIds,
+ contextIds: literalText ? [] : [...map.contextIds],
};
const toFlat = pmToFlat(map, to);
selectionRangeRef.current = {
diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts
index 49b2bf83a9035..7d87f480c4385 100644
--- a/apps/web/src/components/GitActionsControl.logic.test.ts
+++ b/apps/web/src/components/GitActionsControl.logic.test.ts
@@ -5,6 +5,7 @@ import { assert, describe, it } from "vite-plus/test";
import {
buildMenuItems,
requiresDefaultBranchConfirmation,
+ resolveChangesTotals,
resolveDefaultBranchActionDialogCopy,
resolveLiveThreadBranchUpdate,
resolveQuickAction,
@@ -247,3 +248,53 @@ describe("resolveAutoFeatureBranchName", () => {
assert.equal(ref, "feature/ticket-1-2");
});
});
+
+describe("resolveChangesTotals", () => {
+ it("does not let an empty branchChanges hide a dirty working tree", () => {
+ assert.deepEqual(
+ resolveChangesTotals(
+ status({
+ hasWorkingTreeChanges: true,
+ branchChanges: { baseRef: "main", insertions: 0, deletions: 0 },
+ workingTree: {
+ files: [{ path: "a.ts", insertions: 5, deletions: 1 }],
+ insertions: 5,
+ deletions: 1,
+ },
+ }),
+ ),
+ { insertions: 5, deletions: 1 },
+ );
+ });
+
+ it("keeps non-zero branchChanges over the working tree", () => {
+ assert.deepEqual(
+ resolveChangesTotals(
+ status({
+ branchChanges: { baseRef: "main", insertions: 20, deletions: 3 },
+ workingTree: {
+ files: [{ path: "a.ts", insertions: 1, deletions: 0 }],
+ insertions: 1,
+ deletions: 0,
+ },
+ }),
+ ),
+ { insertions: 20, deletions: 3 },
+ );
+ });
+
+ it("falls back to the working tree when branchChanges is omitted", () => {
+ assert.deepEqual(
+ resolveChangesTotals(
+ status({
+ workingTree: {
+ files: [{ path: "a.ts", insertions: 2, deletions: 0 }],
+ insertions: 2,
+ deletions: 0,
+ },
+ }),
+ ),
+ { insertions: 2, deletions: 0 },
+ );
+ });
+});
diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts
index 98a55c3577e01..ca396893accda 100644
--- a/apps/web/src/components/GitActionsControl.logic.ts
+++ b/apps/web/src/components/GitActionsControl.logic.ts
@@ -425,3 +425,28 @@ export function resolveLiveThreadBranchUpdate(input: {
branch: input.gitStatus.refName,
};
}
+
+export type ChangesLineTotals = {
+ readonly insertions: number;
+ readonly deletions: number;
+};
+
+// `branchChanges ?? workingTree` treats `{0,0}` as present, so a zero branch total hid a dirty
+// working tree and the Changes row read +0 while Diff still showed the work.
+export function resolveChangesTotals(
+ status: VcsStatusResult | null | undefined,
+): ChangesLineTotals | null {
+ if (!status) return null;
+ const branch = status.branchChanges;
+ const tree = status.workingTree;
+ if (branch && (branch.insertions > 0 || branch.deletions > 0)) {
+ return { insertions: branch.insertions, deletions: branch.deletions };
+ }
+ if (tree.insertions > 0 || tree.deletions > 0) {
+ return { insertions: tree.insertions, deletions: tree.deletions };
+ }
+ if (branch) {
+ return { insertions: branch.insertions, deletions: branch.deletions };
+ }
+ return { insertions: tree.insertions, deletions: tree.deletions };
+}
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 5f0ffddfc7548..0069e82556b39 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -52,6 +52,7 @@ import {
type GitQuickAction,
type DefaultBranchConfirmableAction,
requiresDefaultBranchConfirmation,
+ resolveChangesTotals,
resolveDefaultBranchActionDialogCopy,
resolveFeatureBranchUnavailableReason,
resolveGitActionProgressPresentation,
@@ -1044,7 +1045,10 @@ export default function GitActionsControl({
const isRepo = gitStatus?.isRepo ?? true;
const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
const gitStatusForActions = gitStatus;
- const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree;
+ const changesTotals = resolveChangesTotals(gitStatusForActions) ?? {
+ insertions: 0,
+ deletions: 0,
+ };
const allFiles = gitStatusForActions?.workingTree.files ?? [];
// Coder: status paths are repository-relative, and the Files surface opens paths relative to
@@ -1125,8 +1129,8 @@ export default function GitActionsControl({
gitActionProgress === null &&
!gitStatusError &&
quickAction.disabled &&
- !changesTotals?.insertions &&
- !changesTotals?.deletions;
+ !changesTotals.insertions &&
+ !changesTotals.deletions;
const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
? resolveDefaultBranchActionDialogCopy({
action: pendingDefaultBranchAction.action,
@@ -1616,19 +1620,17 @@ export default function GitActionsControl({
- +{formatDiffCount(changesTotals?.insertions ?? 0)}
+ +{formatDiffCount(changesTotals.insertions)}
- -{formatDiffCount(changesTotals?.deletions ?? 0)}
+ -{formatDiffCount(changesTotals.deletions)}
diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts
new file mode 100644
index 0000000000000..05602fda4c4c5
--- /dev/null
+++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vite-plus/test";
+import type { ServerConfigStreamEvent } from "@t3tools/contracts";
+
+import { createKeybindingsUpdateToastController } from "./KeybindingsUpdateToast.logic";
+
+function keybindingsUpdated(issueMessages: ReadonlyArray): ServerConfigStreamEvent {
+ return {
+ version: 1,
+ type: "keybindingsUpdated",
+ payload: {
+ keybindings: [],
+ issues: issueMessages.map((message, index) => ({
+ kind: "keybindings.invalid-entry" as const,
+ message,
+ index,
+ })),
+ },
+ };
+}
+
+describe("keybindings update toast", () => {
+ it("shows one warning when a save and the file watcher report the same issue", () => {
+ let currentTime = 0;
+ const controller = createKeybindingsUpdateToastController({ now: () => currentTime });
+ const invalid = { _tag: "InvalidConfiguration", message: "unknown command" };
+
+ expect(controller.handle(keybindingsUpdated(["unknown command"]))).toEqual(invalid);
+ currentTime = 100;
+ expect(controller.handle(keybindingsUpdated(["unknown command"]))).toBeNull();
+
+ currentTime = 200;
+ expect(controller.handle(keybindingsUpdated(["bad shortcut"]))).toEqual({
+ _tag: "InvalidConfiguration",
+ message: "bad shortcut",
+ });
+
+ currentTime = 5_000;
+ expect(controller.handle(keybindingsUpdated(["bad shortcut"]))).toEqual({
+ _tag: "InvalidConfiguration",
+ message: "bad shortcut",
+ });
+ currentTime = 5_100;
+ expect(controller.handle(keybindingsUpdated([]))).toEqual({ _tag: "Success" });
+ currentTime = 5_200;
+ expect(controller.handle(keybindingsUpdated([]))).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts
index ed13e6a1c800b..f7724f38d13b5 100644
--- a/apps/web/src/components/KeybindingsUpdateToast.logic.ts
+++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts
@@ -1,6 +1,6 @@
import type { ServerConfigStreamEvent } from "@t3tools/contracts";
-const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000;
+const KEYBINDINGS_TOAST_COOLDOWN_MS = 2_000;
export type KeybindingsUpdateToastDecision =
| { readonly _tag: "Success" }
@@ -14,7 +14,7 @@ export function createKeybindingsUpdateToastController(input: {
readonly now?: () => number;
}): KeybindingsUpdateToastController {
const now = input.now ?? Date.now;
- let lastSuccessToastAt: number | null = null;
+ let lastToast: { readonly key: string; readonly at: number } | null = null;
return {
handle: (event) => {
@@ -23,23 +23,24 @@ export function createKeybindingsUpdateToastController(input: {
}
const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
- if (issue) {
- return {
- _tag: "InvalidConfiguration",
- message: issue.message,
- };
- }
+ const decision: KeybindingsUpdateToastDecision = issue
+ ? { _tag: "InvalidConfiguration", message: issue.message }
+ : { _tag: "Success" };
+ // A save and the server's file watcher each publish the same snapshot,
+ // so an identical toast inside the cooldown is the same change twice.
+ const key = issue ? `invalid:${issue.message}` : "success";
const currentTime = now();
if (
- lastSuccessToastAt !== null &&
- currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS
+ lastToast !== null &&
+ lastToast.key === key &&
+ currentTime - lastToast.at < KEYBINDINGS_TOAST_COOLDOWN_MS
) {
return null;
}
- lastSuccessToastAt = currentTime;
- return { _tag: "Success" };
+ lastToast = { key, at: currentTime };
+ return decision;
},
};
}
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index fde82414afa79..669c805492bcc 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -2,12 +2,11 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox";
import * as DateTime from "effect/DateTime";
import { deriveActiveWorkStartedAt } from "../session-logic.ts";
-import { describe, expect, it, vi } from "vite-plus/test";
+import { describe, expect, it } from "vite-plus/test";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import {
shouldCreateNewThreadInCurrentProject,
- archiveSelectedThreadEntries,
deleteSelectedThreadEntries,
getFallbackThreadIdAfterDelete,
hasUnseenCompletion,
@@ -16,7 +15,6 @@ import {
isSidebarEnvironmentDisconnected,
orderItemsByPreferredIds,
pinOrderKeyBetween,
- resolveProjectStatusIndicator,
resolveSidebarSweepKeys,
resolveSidebarThreadStatus,
resolveThreadStatusPill,
@@ -28,7 +26,6 @@ import {
planSidebarThreadDrop,
resolveSidebarReorderableThreadKeys,
sortPinnedThreadsForSidebar,
- sortProjectsForSidebar,
sortScopedProjectsForSidebar,
sortSidebarV2ProjectGroups,
shouldNavigateAfterThreadPark,
@@ -140,43 +137,6 @@ describe("deleteSelectedThreadEntries", () => {
});
});
-describe("archiveSelectedThreadEntries", () => {
- const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
- const success = { _tag: "Success" } as const;
- const failure = { _tag: "Failure" } as const;
-
- it("stops at a mutation failure and retains prior successes", async () => {
- const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
- if (entry.threadKey === "two") return failure;
- onArchived();
- return success;
- });
- const outcome = await archiveSelectedThreadEntries({ entries, archive });
-
- expect(archive).toHaveBeenCalledTimes(2);
- expect(outcome).toEqual({
- archivedThreadKeys: ["one"],
- mutationFailure: failure,
- followupFailures: [],
- });
- });
-
- it("continues after a post-archive failure", async () => {
- const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
- onArchived();
- return entry.threadKey === "two" ? failure : success;
- });
- const outcome = await archiveSelectedThreadEntries({ entries, archive });
-
- expect(archive).toHaveBeenCalledTimes(3);
- expect(outcome).toEqual({
- archivedThreadKeys: ["one", "two", "three"],
- mutationFailure: null,
- followupFailures: [failure],
- });
- });
-});
-
describe("sidebar thread lineage helpers", () => {
it("maps an open subagent to its nearest visible ancestor's sidebar row", () => {
const environmentId = EnvironmentId.make("environment-row");
@@ -589,40 +549,6 @@ describe("resolveThreadStatusPill", () => {
});
});
-describe("resolveProjectStatusIndicator", () => {
- it("ranks waiting below active work and above plan-ready", () => {
- const waiting = {
- label: "Waiting" as const,
- colorClass: "text-sidebar-muted-foreground",
- dotClass: "bg-sidebar-muted-foreground",
- pulse: false,
- };
-
- expect(
- resolveProjectStatusIndicator([
- waiting,
- {
- label: "Working",
- colorClass: "text-sky-600",
- dotClass: "bg-sky-500",
- pulse: true,
- },
- ]),
- ).toMatchObject({ label: "Working" });
- expect(
- resolveProjectStatusIndicator([
- {
- label: "Plan Ready",
- colorClass: "text-violet-600",
- dotClass: "bg-violet-500",
- pulse: false,
- },
- waiting,
- ]),
- ).toMatchObject({ label: "Waiting" });
- });
-});
-
function makeProject(overrides: Partial = {}): Project {
const { defaultModelSelection, ...rest } = overrides;
return {
@@ -703,54 +629,6 @@ describe("getFallbackThreadIdAfterDelete", () => {
expect(fallbackThreadId).toBe(ThreadId.make("thread-next"));
});
});
-describe("sortProjectsForSidebar", () => {
- it("sorts projects by the most recent user message across their threads", () => {
- const projects = [
- makeProject({ id: ProjectId.make("project-1"), title: "Older project" }),
- makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }),
- ];
- const threads = [
- makeThread({
- projectId: ProjectId.make("project-1"),
- updatedAt: "2026-03-09T10:20:00.000Z",
- messages: [
- {
- id: "message-1" as never,
- role: "user",
- text: "older project user message",
- runId: null,
- createdAt: "2026-03-09T10:01:00.000Z",
- updatedAt: "2026-03-09T10:01:00.000Z",
- streaming: false,
- },
- ],
- }),
- makeThread({
- id: ThreadId.make("thread-2"),
- projectId: ProjectId.make("project-2"),
- updatedAt: "2026-03-09T10:05:00.000Z",
- messages: [
- {
- id: "message-2" as never,
- role: "user",
- text: "newer project user message",
- runId: null,
- createdAt: "2026-03-09T10:05:00.000Z",
- updatedAt: "2026-03-09T10:05:00.000Z",
- streaming: false,
- },
- ],
- }),
- ];
-
- const sorted = sortProjectsForSidebar(projects, threads, "updated_at");
-
- expect(sorted.map((project) => project.id)).toEqual([
- ProjectId.make("project-2"),
- ProjectId.make("project-1"),
- ]);
- });
-});
describe("sortScopedProjectsForSidebar", () => {
it("keeps identical project ids in different environments separate", () => {
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index b60bbf5070488..653d7b9df0964 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -583,38 +583,6 @@ export async function deleteSelectedThreadEntries<
return { deletedThreadKeys, firstFailure };
}
-export async function archiveSelectedThreadEntries<
- TEntry extends { readonly threadKey: string },
- TResult extends { readonly _tag: "Success" | "Failure" },
->(input: {
- entries: readonly TEntry[];
- archive: (entry: TEntry, onArchived: () => void) => Promise;
-}): Promise<{
- archivedThreadKeys: readonly string[];
- mutationFailure: Extract | null;
- followupFailures: readonly Extract[];
-}> {
- const archivedThreadKeys: string[] = [];
- const followupFailures: Extract[] = [];
-
- for (const entry of input.entries) {
- let didArchive = false;
- const result = await input.archive(entry, () => {
- didArchive = true;
- });
- if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey);
- if (result._tag === "Success") continue;
- const failure = result as Extract;
- if (didArchive) {
- followupFailures.push(failure);
- continue;
- }
- return { archivedThreadKeys, mutationFailure: failure, followupFailures };
- }
-
- return { archivedThreadKeys, mutationFailure: null, followupFailures };
-}
-
export function isSidebarHiddenThread(thread: Pick): boolean {
return !isListedThreadLineage(thread.lineage);
}
@@ -705,17 +673,6 @@ export interface ThreadStatusPill {
pulse: boolean;
}
-const THREAD_STATUS_PRIORITY: Record = {
- "Pending Approval": 5,
- "Awaiting Input": 4,
- Failed: 3.5,
- Working: 3,
- Connecting: 3,
- Waiting: 2.5,
- "Plan Ready": 2,
- Completed: 1,
-};
-
type ThreadStatusInput = Pick<
SidebarThreadSummary,
| "hasActionableProposedPlan"
@@ -1240,24 +1197,6 @@ export function resolveThreadStatusPill(input: {
return null;
}
-export function resolveProjectStatusIndicator(
- statuses: ReadonlyArray,
-): ThreadStatusPill | null {
- let highestPriorityStatus: ThreadStatusPill | null = null;
-
- for (const status of statuses) {
- if (status === null) continue;
- if (
- highestPriorityStatus === null ||
- THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label]
- ) {
- highestPriorityStatus = status;
- }
- }
-
- return highestPriorityStatus;
-}
-
export function getFallbackThreadIdAfterDelete<
T extends Pick & ThreadSortInput,
>(input: {
@@ -1326,29 +1265,6 @@ function sortProjectsByActivity(
.map(({ project }) => project);
}
-export function sortProjectsForSidebar<
- TProject extends SidebarProject,
- TThread extends Pick & ThreadSortInput,
->(
- projects: readonly TProject[],
- threads: readonly TThread[],
- sortOrder: SidebarProjectSortOrder,
-): TProject[] {
- const threadsByProjectId = new Map();
- for (const thread of threads) {
- const existing = threadsByProjectId.get(thread.projectId) ?? [];
- existing.push(thread);
- threadsByProjectId.set(thread.projectId, existing);
- }
-
- return sortProjectsByActivity(
- projects,
- sortOrder,
- (project) => threadsByProjectId.get(project.id) ?? [],
- (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id),
- );
-}
-
export function sortLogicalProjectsForSidebar<
TProject extends LogicalSidebarProject,
TThread extends ScopedSidebarThread,
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts
index 2bba09f54dcb1..a89e4159cae10 100644
--- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts
+++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts
@@ -1,5 +1,4 @@
-import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { describe, expect, it, vi } from "vite-plus/test";
import {
createThreadNotificationGate,
@@ -30,164 +29,85 @@ function harness() {
}
describe("createThreadNotificationGate", () => {
- beforeEach(() => {
- vi.useFakeTimers();
- });
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it("drops a completion when the thread resumes within the grace", () => {
- const { notify, observe } = harness();
- observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1);
- observe(working);
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(notify).not.toHaveBeenCalled();
- });
-
- it("notifies a real completion once after the grace", () => {
+ it("notifies a completion immediately and only once", () => {
const { notify, observe } = harness();
observe(working);
observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1);
- expect(notify).not.toHaveBeenCalled();
- vi.advanceTimersByTime(1);
expect(notify.mock.calls).toEqual([["thread", "completion"]]);
observe({ ...completed(1) });
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(notify).toHaveBeenCalledTimes(1);
- });
-
- it("notifies attention at once and replaces a pending completion", () => {
- const { notify, observe } = harness();
observe(working);
observe(completed(1));
- vi.advanceTimersByTime(500);
- observe({ attention: "run-1:approval", settled: false, completedAt: null });
- expect(notify.mock.calls).toEqual([["thread", "input"]]);
- vi.advanceTimersByTime(60_000);
- // The request resolves and the same run reads as completed again.
- observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(notify.mock.calls).toEqual([["thread", "input"]]);
+ expect(notify).toHaveBeenCalledTimes(1);
});
- it("notifies one completion across continuation gaps", () => {
+ it("notifies each newer completion immediately", () => {
const { notify, observe } = harness();
observe(working);
observe(completed(1));
- vi.advanceTimersByTime(300);
observe(working);
- vi.advanceTimersByTime(5_000);
observe(completed(2));
- vi.advanceTimersByTime(300);
- observe(working);
- vi.advanceTimersByTime(5_000);
- observe(completed(3));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS);
- expect(notify.mock.calls).toEqual([["thread", "completion"]]);
+ expect(notify.mock.calls).toEqual([
+ ["thread", "completion"],
+ ["thread", "completion"],
+ ]);
+ observe(completed(1));
+ expect(notify).toHaveBeenCalledTimes(2);
});
- it("keeps a completion interrupted by attention after a working gap from firing", () => {
+ it("notifies attention once and still notifies the subsequent completion", () => {
const { notify, observe } = harness();
observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(500);
+ const attention = { attention: "run-1:approval", settled: false, completedAt: null };
+ observe(attention);
+ expect(notify.mock.calls).toEqual([["thread", "input"]]);
+ observe({ ...attention });
+ expect(notify).toHaveBeenCalledTimes(1);
observe(working);
- observe({ attention: "run-1:approval", settled: false, completedAt: null });
- vi.advanceTimersByTime(60_000);
observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(notify.mock.calls).toEqual([["thread", "input"]]);
+ expect(notify.mock.calls).toEqual([
+ ["thread", "input"],
+ ["thread", "completion"],
+ ]);
});
- it("adds nothing when attention follows a completion that already fired", () => {
+ it("does not replay a completion after attention resolves", () => {
const { notify, observe } = harness();
observe(working);
observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS);
observe({ attention: "run-1:limited", settled: false, completedAt: null });
observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
expect(notify.mock.calls).toEqual([
["thread", "completion"],
["thread", "input"],
]);
});
- it("still notifies a newer completion after attention", () => {
+ it("never notifies existing, archived or stopped completions, including when unarchived", () => {
const { notify, observe } = harness();
- observe(working);
observe(completed(1));
- vi.advanceTimersByTime(500);
- observe({ attention: "run-2:approval", settled: false, completedAt: null });
observe(working);
+ observe(completed(2), { ...visible, quiet: true });
observe(completed(2));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS);
- expect(notify.mock.calls).toEqual([
- ["thread", "input"],
- ["thread", "completion"],
- ]);
- });
-
- it("ignores unrelated updates while a completion waits", () => {
- const { notify, observe } = harness();
observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(1_500);
- observe({ ...completed(1) });
- vi.advanceTimersByTime(500);
- expect(notify).toHaveBeenCalledTimes(1);
- });
-
- it("notifies a run that settles again after a brief runtime blip", () => {
- const { notify, observe } = harness();
- observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(500);
+ observe(completed(3), { ...visible, archived: true });
+ observe(completed(3));
+ expect(notify).not.toHaveBeenCalled();
observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS);
+ observe(completed(4));
expect(notify.mock.calls).toEqual([["thread", "completion"]]);
});
- it("restarts the grace when a newer completion replaces a pending one", () => {
- const { notify, observe } = harness();
- observe(working);
- observe(completed(1));
- vi.advanceTimersByTime(1_500);
- observe(completed(2));
- vi.advanceTimersByTime(1_500);
- expect(notify).not.toHaveBeenCalled();
- vi.advanceTimersByTime(500);
- expect(notify).toHaveBeenCalledTimes(1);
- });
-
- it("drops a pending completion on archive, stop request, removal or clear", () => {
- for (const end of [
- (h: ReturnType) => h.observe(completed(1), { ...visible, archived: true }),
- (h: ReturnType) =>
- h.observe({ ...completed(1) }, { ...visible, quiet: true }),
- (h: ReturnType) => h.gate.retain(new Set()),
- (h: ReturnType) => h.gate.clear(),
+ it("forgets handled completions when a thread is removed or the gate is cleared", () => {
+ for (const reset of [
+ (gate: ReturnType>) => gate.retain(new Set()),
+ (gate: ReturnType>) => gate.clear(),
]) {
- const h = harness();
- h.observe(working);
- h.observe(completed(1));
- end(h);
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(h.notify).not.toHaveBeenCalled();
+ const { gate, notify, observe } = harness();
+ observe(completed(1));
+ reset(gate);
+ gate.observe("thread", undefined, working, visible);
+ gate.observe("thread", working, completed(1), visible);
+ expect(notify.mock.calls).toEqual([["thread", "completion"]]);
}
});
-
- it("never notifies a stopped run or the first observation", () => {
- const { notify, observe } = harness();
- observe(completed(1));
- observe(working);
- observe(completed(2), { ...visible, quiet: true });
- vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2);
- expect(notify).not.toHaveBeenCalled();
- });
});
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts
index fdbfb7ae1f49d..1fe126ad37da3 100644
--- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts
+++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts
@@ -1,5 +1,3 @@
-import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox";
-
export type ThreadNotificationKind = "input" | "completion";
export interface ThreadNotificationObservation {
@@ -11,23 +9,11 @@ export interface ThreadNotificationObservation {
readonly completedAt: number | null;
}
-/**
- * Attention notifies at once. Completion waits out the Working shelf's exit grace,
- * because an orchestrator run can end moments before its continuation starts. Any
- * observation that is not settled during the wait cancels it.
- */
export function createThreadNotificationGate(
notify: (key: Key, kind: ThreadNotificationKind) => void,
) {
- const pending = new Map }>();
// The newest completion already notified, or deliberately skipped.
const handled = new Map();
- // The newest completion seen settled, kept while the thread works or waits.
- const lastSettled = new Map();
- const cancel = (key: Key) => {
- clearTimeout(pending.get(key)?.timer);
- pending.delete(key);
- };
const skip = (key: Key, at: number | null) => {
if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at);
};
@@ -38,44 +24,24 @@ export function createThreadNotificationGate(
next: ThreadNotificationObservation,
options: { readonly archived: boolean; readonly quiet: boolean },
): void {
- if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt);
- if (!next.settled || options.archived || options.quiet) cancel(key);
// The first sighting, archived threads, and stopped or silent runs never notify completion.
if (!prior || options.archived || options.quiet) skip(key, next.completedAt);
if (!prior || options.archived) return;
if (next.attention && next.attention !== prior.attention) {
- // Attention replaces the completion before it, so that run must not notify later.
- skip(key, lastSettled.get(key) ?? null);
- cancel(key);
notify(key, "input");
return;
}
const at = next.completedAt;
if (!next.settled || at === null || at <= (handled.get(key) ?? -Infinity)) return;
- if (pending.get(key)?.at === at) return;
- cancel(key);
- pending.set(key, {
- at,
- timer: setTimeout(() => {
- pending.delete(key);
- handled.set(key, at);
- notify(key, "completion");
- }, WORKING_EXIT_GRACE_MS),
- });
+ handled.set(key, at);
+ notify(key, "completion");
},
/** Forgets threads that left the snapshot. */
retain(keys: ReadonlySet): void {
- for (const key of pending.keys()) if (!keys.has(key)) cancel(key);
for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key);
- for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key);
- },
- cancelPending(): void {
- for (const key of pending.keys()) cancel(key);
},
clear(): void {
- for (const key of pending.keys()) cancel(key);
handled.clear();
- lastSettled.clear();
},
};
}
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx
index 2c50f8ad0159f..143de7a0157d4 100644
--- a/apps/web/src/components/ThreadNotificationCoordinator.tsx
+++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx
@@ -5,6 +5,7 @@ import {
} from "@t3tools/client-runtime/state/models";
import { isShownUnderParent } from "@t3tools/client-runtime/state/threads";
import type { AnyRouter } from "@tanstack/react-router";
+import { threadKey } from "@t3tools/client-runtime/state/entities";
import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
import type { ClientSettings } from "@t3tools/contracts/settings";
import * as Option from "effect/Option";
@@ -44,6 +45,8 @@ interface NotificationState extends ThreadNotificationObservation {
readonly status: SidebarThreadStatus;
}
+type ToastId = ReturnType;
+
interface WatchedEnvironment {
unsubscribe: () => void;
previous: Map;
@@ -59,27 +62,67 @@ export function startThreadNotifications(
registry: AtomRegistry.AtomRegistry,
router: AnyRouter,
): () => void {
- const pending = new Map();
+ const pending = new Map<
+ string,
+ { environmentId: EnvironmentId; threadId: ThreadId; notification: Notification }
+ >();
const watched = new Map();
- const trackNotification = (environmentId: EnvironmentId, notification: Notification) => {
+ const trackNotification = (
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ notification: Notification,
+ ) => {
pending.get(notification.tag)?.notification.close();
- pending.set(notification.tag, { environmentId, notification });
+ pending.set(notification.tag, { environmentId, threadId, notification });
setNotificationBadge(pending.size);
};
+ // Toast ids by thread key, so hiding a thread can close its open toasts.
+ const openToasts = new Map>();
+ const forgetToast = (key: string, toastId: ToastId) => {
+ const remaining = (openToasts.get(key) ?? []).filter((id) => id !== toastId);
+ if (remaining.length === 0) openToasts.delete(key);
+ else openToasts.set(key, remaining);
+ };
+ // A notification shown before the tag filter turned on must not stay on screen. While hiding,
+ // one whose thread left the live list (archived) cannot be judged, so it closes too.
+ const closeHiddenPending = () => {
+ const hidden = registry.get(environmentThreadShells.hiddenThreadKeysAtom);
+ const live = registry.get(environmentThreadShells.anyEnvironmentHidingAtom)
+ ? new Set(registry.get(environmentThreadShells.threadRefsAtom).map(threadKey))
+ : null;
+ const shouldClose = (key: string) => hidden.has(key) || (live !== null && !live.has(key));
+ for (const [key, toastIds] of openToasts) {
+ if (!shouldClose(key)) continue;
+ for (const toastId of toastIds) toastManager.close(toastId);
+ openToasts.delete(key);
+ }
+ const count = pending.size;
+ for (const [tag, entry] of pending) {
+ if (!shouldClose(threadKey(entry))) continue;
+ entry.notification.close();
+ pending.delete(tag);
+ }
+ if (count !== pending.size) setNotificationBadge(pending.size);
+ };
const clearPending = () => {
for (const { notification } of pending.values()) notification.close();
pending.clear();
setNotificationBadge(0);
};
- // Completions fire from a timer, so read settings, focus and route when presenting.
const present = (
environmentId: EnvironmentId,
state: NotificationState,
kind: ThreadNotificationKind,
) => {
const { thread, status } = state;
+ if (
+ registry
+ .get(environmentThreadShells.hiddenThreadKeysAtom)
+ .has(threadKey({ environmentId, threadId: thread.id }))
+ )
+ return;
const { notificationMode: mode, inAppNotificationsEnabled } = getClientSettings();
const active = (router.state.matches.at(-1)?.params ?? {}) as {
environmentId?: string;
@@ -108,7 +151,9 @@ export function startThreadNotifications(
document.hasFocus() &&
(active.environmentId !== environmentId || active.threadId !== thread.id)
) {
- const toastId = toastManager.add({
+ const toastKey = threadKey({ environmentId, threadId: thread.id });
+ const toastId: ToastId = toastManager.add({
+ onRemove: (): void => forgetToast(toastKey, toastId),
type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning",
title,
description: thread.title,
@@ -138,6 +183,7 @@ export function startThreadNotifications(
},
},
});
+ openToasts.set(toastKey, [...(openToasts.get(toastKey) ?? []), toastId]);
return;
}
if (
@@ -155,7 +201,7 @@ export function startThreadNotifications(
// Windows drops clicks after a toast moves into Notification Center.
requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform),
});
- trackNotification(environmentId, notification);
+ trackNotification(environmentId, thread.id, notification);
notification.addEventListener("click", () => {
notification.close();
window.focus();
@@ -261,7 +307,6 @@ export function startThreadNotifications(
observeThreads(environmentId, entry, threads);
return;
}
- entry.gate.cancelPending();
entry.reconnecting = true;
},
{ immediate: true },
@@ -355,11 +400,17 @@ export function startThreadNotifications(
syncEnvironments();
},
);
+ const unsubscribeHidden = [
+ registry.subscribe(environmentThreadShells.hiddenThreadKeysAtom, closeHiddenPending),
+ registry.subscribe(environmentThreadShells.anyEnvironmentHidingAtom, closeHiddenPending),
+ registry.subscribe(environmentThreadShells.threadRefsAtom, closeHiddenPending),
+ ];
onSettings();
return () => {
unsubscribeSettings();
unsubscribeEnvironmentIds();
+ for (const unsubscribe of unsubscribeHidden) unsubscribe();
for (const entry of watched.values()) entry.unsubscribe();
watched.clear();
stopModeListeners();
diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx
index d9a8e1ffd681e..b7d0ce73e6271 100644
--- a/apps/web/src/components/ThreadRouteView.tsx
+++ b/apps/web/src/components/ThreadRouteView.tsx
@@ -1,5 +1,9 @@
+import { useAtomValue } from "@effect/atom-react";
import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities";
+import { visibilityParentKey } from "@t3tools/client-runtime/state/threads";
+import * as Option from "effect/Option";
import { Navigate, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
@@ -8,9 +12,13 @@ import { BackgroundThreadContext } from "./chat/backgroundThread";
import { SidebarInset } from "./ui/sidebar";
import { useComposerDraftStore } from "../composerDraftStore";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
-import { useEnvironmentThreadRefs, useQuietThreadShell } from "../state/entities";
+import {
+ useEnvironmentThreadRefs,
+ useAnyEnvironmentHiding,
+ useQuietThreadShell,
+} from "../state/entities";
import { useEnvironmentShellStatus } from "../state/shell";
-import { useEnvironmentThread } from "../state/threads";
+import { environmentThreadShells, useEnvironmentThread } from "../state/threads";
import {
buildThreadRouteParams,
isThreadRouteSnapshotAuthoritative,
@@ -81,6 +89,28 @@ export function ThreadRouteView({
unlistedThreadRef?.environmentId ?? null,
unlistedThreadRef?.threadId ?? null,
);
+ const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom);
+ const unlistedThread = Option.getOrNull(Option.map(unlistedThreadDetail.data, (p) => p.thread));
+ const unlistedParentKey =
+ unlistedThreadRef === null || unlistedThread === null
+ ? null
+ : visibilityParentKey(unlistedThreadRef.environmentId, unlistedThread);
+ const unlistedParentShell = useQuietThreadShell(
+ unlistedParentKey === null ? null : parseThreadKey(unlistedParentKey),
+ );
+ const hidingAnywhere = useAnyEnvironmentHiding();
+ const draftRedirectShell = useQuietThreadShell(draftRedirect ?? null);
+ const hiddenByTagFilter = serverThreadRef !== null && hiddenKeys.has(threadKey(serverThreadRef));
+ // Cached detail can predate a tag or an archive, so while hiding an off-shell thread opens only
+ // as the child of a loaded, visible parent (lineage never changes); anything else stays closed.
+ const offShellBlocked =
+ hidingAnywhere &&
+ unlistedThreadRef !== null &&
+ !(
+ unlistedParentKey !== null &&
+ unlistedParentShell !== null &&
+ !hiddenKeys.has(unlistedParentKey)
+ );
const renderState = resolveThreadRouteRenderState({
bootstrapComplete,
serverThreadExists: serverThreadShell !== null,
@@ -107,7 +137,15 @@ export function ThreadRouteView({
let view: React.ReactNode = null;
let redirect: React.ReactNode = null;
if (target.kind === "draft") {
- if (draftSession) {
+ if (
+ draftSession &&
+ hiddenKeys.has(
+ threadKey({ environmentId: draftSession.environmentId, threadId: draftSession.threadId }),
+ )
+ ) {
+ // A draft whose thread is already live and hidden (mid-promotion): never mount it.
+ redirect = ;
+ } else if (draftSession) {
view = (
);
+ } else if (draftRedirect && hiddenKeys.has(threadKey(draftRedirect))) {
+ // Back to a promoted draft whose thread is now hidden: never mount it.
+ redirect = ;
+ } else if (draftRedirect && hidingAnywhere && draftRedirectShell === null) {
+ // While hiding, a thread outside the live list is judged by the server route, unmounted here.
+ redirect = (
+
+ );
} else if (draftRedirect) {
// Same slot and key as the server route renders, so the swap keeps this instance.
view = (
@@ -138,6 +188,11 @@ export function ThreadRouteView({
} else {
redirect = ;
}
+ } else if (hiddenByTagFilter) {
+ redirect = ;
+ } else if (offShellBlocked) {
+ // Before the shell bootstraps, a listed visible thread can still look off-shell.
+ if (bootstrapComplete && unlistedThread !== null) redirect = ;
} else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) {
view = (
({ resolvedQueries: new Set() }));
+
+const REFS: VcsRef[] = ["develop", "main", "origin/main", "origin/feature/main-nav"].map(
+ (name) => ({ name, current: false, isDefault: false, worktreePath: null }),
+);
+
+// Like the real hook, a query has no refs until the server answers it.
+vi.mock(import("../state/queries"), async (original) => ({
+ ...(await original()),
+ usePaginatedBranches: (target) => {
+ const query = target.query ?? "";
+ const resolved = mocks.resolvedQueries.has(query);
+ const refs = resolved ? REFS.filter((ref) => ref.name.includes(query)) : [];
+ return {
+ data: resolved
+ ? { refs, isRepo: true, hasPrimaryRemote: true, nextCursor: null, totalCount: refs.length }
+ : null,
+ refs,
+ error: null,
+ isPending: !resolved,
+ isFetchingNextPage: false,
+ refresh: () => {},
+ loadNext: () => {},
+ };
+ },
+}));
+
+import "~/index.css";
+import { render } from "../../test/render";
+import { WorktreeBaseBranchPicker } from "./WorktreeBaseBranchPicker";
+
+function setup() {
+ mocks.resolvedQueries = new Set([""]);
+}
+
+describe("WorktreeBaseBranchPicker", () => {
+ it.each([
+ { keys: "{Enter}", selected: "main" },
+ { keys: "{ArrowDown}{Enter}", selected: "origin/main" },
+ ])(
+ "$keys selects $selected when each keystroke's results arrive before the next",
+ async ({ keys, selected }) => {
+ setup();
+ const onValueChange = vi.fn();
+ const picker = () => (
+ {}}
+ />
+ );
+ await using screen = await render(picker());
+
+ await userEvent.click(screen.getByRole("combobox"));
+ let query = "";
+ for (const char of "main") {
+ await userEvent.type(page.getByPlaceholder("Search refs..."), char);
+ query += char;
+ mocks.resolvedQueries.add(query);
+ await screen.rerender(picker());
+ }
+
+ await expect
+ .element(page.getByRole("option", { name: "main", exact: true }))
+ .toHaveAttribute("data-highlighted");
+ await userEvent.keyboard(keys);
+ expect(onValueChange).toHaveBeenCalledExactlyOnceWith(selected);
+ },
+ );
+});
diff --git a/apps/web/src/components/WorktreeBaseBranchPicker.tsx b/apps/web/src/components/WorktreeBaseBranchPicker.tsx
index 2fde4980e04c8..7076e5579a4ee 100644
--- a/apps/web/src/components/WorktreeBaseBranchPicker.tsx
+++ b/apps/web/src/components/WorktreeBaseBranchPicker.tsx
@@ -1,6 +1,6 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { ChevronDownIcon, GitBranchIcon } from "lucide-react";
-import { useDeferredValue, useState } from "react";
+import { useState } from "react";
import { usePaginatedBranches } from "../state/queries";
import { useEnvironmentQuery } from "../state/query";
@@ -32,11 +32,10 @@ export function WorktreeBaseBranchPicker({
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
- const deferredQuery = useDeferredValue(query.trim());
const branches = usePaginatedBranches({
environmentId,
cwd,
- query: sanitizeNewRefName(deferredQuery),
+ query: sanitizeNewRefName(query.trim()),
});
const selectedRefQuery = useEnvironmentQuery(
cwd && value
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 94124a0cf97ee..6016594961e57 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -225,25 +225,20 @@ import { PlayIcon } from "lucide-react";
import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
import type { UnifiedSettings } from "@t3tools/contracts/settings";
-import {
- isVideoAttachment,
- type ChatMessage,
- type SessionPhase,
- type Thread,
- type ThreadShell,
-} from "../../types";
+import { isVideoAttachment, type SessionPhase, type Thread, type ThreadShell } from "../../types";
import { type ComposerPromptHistoryPosition } from "./composerPromptHistory";
import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
import type {
LatestProposedPlanState,
PendingApproval,
PendingUserInput,
+ TimelineEntry,
} from "../../session-logic";
import {
resolveComposerDispatchMode,
type ComposerDispatchMode,
} from "@t3tools/client-runtime/state/composer-dispatch";
-import type { ContextWindowSnapshot } from "../../lib/contextWindow";
+import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
import {
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
@@ -355,7 +350,7 @@ export interface ChatComposerProps {
activeThreadId: ThreadId | null;
activeThread: Thread | undefined;
activeThreadShell: ThreadShell | null;
- promptHistoryMessages: ReadonlyArray;
+ promptHistoryEntries: ReadonlyArray;
forceExpandedOnMobile: boolean;
projectSelectionRequired: boolean;
@@ -416,7 +411,9 @@ export interface ChatComposerProps {
reportedModelSelection?: ModelSelection | null;
activeProjectId: ProjectId | null;
- activeContextWindow: ContextWindowSnapshot | null;
+ contextWindowEntries: Parameters[0];
+ contextWindowLiveUsage: Parameters[1];
+ contextWindowProviderThread: Parameters[2];
compactThreadUnavailable: boolean;
compactDisabled: boolean;
compactDisabledReason: string | null;
@@ -507,7 +504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onMultipleModelSelectionsChange: setMultipleModelSelections,
activeThreadId,
activeThread,
- promptHistoryMessages,
+ promptHistoryEntries,
forceExpandedOnMobile,
projectSelectionRequired,
phase,
@@ -540,7 +537,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeThreadModelSelection,
reportedModelSelection,
activeProjectId,
- activeContextWindow,
+ contextWindowEntries,
+ contextWindowLiveUsage,
+ contextWindowProviderThread,
compactThreadUnavailable,
compactDisabled,
compactDisabledReason,
@@ -585,6 +584,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
editingQueuedAttachments,
onRemoveEditingQueuedAttachment,
} = props;
+ const activeContextWindow = deriveLatestContextWindowSnapshot(
+ contextWindowEntries,
+ contextWindowLiveUsage,
+ contextWindowProviderThread,
+ );
const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
const {
heldRequestIds,
@@ -2071,7 +2075,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
planModeUiEnabled,
isLiteralPendingAnswer,
pendingUserInputs,
- promptHistoryMessages,
+ promptHistoryEntries,
promptHistoryPositionRef,
composerDraftTarget,
promptRef,
@@ -2352,7 +2356,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const addComposerFolders = (_folders: File[]) => {
toastManager.add({
type: "error",
- title: "Folders can only be referenced in the local desktop environment",
+ title: "Folders can only be referenced on a server running on this computer",
});
};
const addComposerFoldersStable = useStableCallback(addComposerFolders);
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index b2580e3b54d89..8f55e1618ecfc 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -26,7 +26,7 @@ import {
buildSidebarProjectSnapshots,
projectGroupsSpanEnvironments,
} from "~/sidebarProjectGrouping";
-import { useProjects, useThreadShells } from "~/state/entities";
+import { useProjects, useVisibleThreadShells } from "~/state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
import { ProjectFavicon } from "../ProjectFavicon";
@@ -67,7 +67,7 @@ export function DraftHeroHeadline({
activeProjectTitle,
}: DraftHeroHeadlineProps) {
const projects = useProjects();
- const threads = useThreadShells();
+ const threads = useVisibleThreadShells();
const { environments } = useEnvironments();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
index 2fcaa596a5b6c..9b156deaed8a2 100644
--- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
@@ -217,6 +217,45 @@ const TASK_ID = ScheduledTaskId.make("task-1");
const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates.";
describe("MessagesTimeline work rows", () => {
+ it("keeps the worked-for heading in place when expanded at the bottom", async () => {
+ const props = baseProps();
+ const runId = RunId.make("completed-run");
+ const container = document.createElement("div");
+ container.style.cssText = "height: 600px; width: 900px; display: flex; flex-direction: column;";
+ await using screen = await render(
+
+ message(`Earlier context ${index}`, {}, `context-${index}`),
+ ),
+ message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"),
+ message("Done", { role: "assistant", runId }, "final"),
+ ]}
+ />,
+ { container },
+ );
+ const heading = screen.getByRole("button", { name: /^Worked for/ });
+ await expect.element(heading).toBeVisible();
+ const viewport = props.listRef.current!.getScrollableNode()!;
+ await vi.waitFor(() =>
+ expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2),
+ );
+ expect(viewport.scrollTop).toBeGreaterThan(0);
+ const top = heading.element().getBoundingClientRect().top;
+ const height = viewport.scrollHeight;
+ using _timers = fakeTimers();
+ await heading.click();
+ await act(() => vi.advanceTimersByTime(1000));
+ await vi.waitFor(() => {
+ expect(viewport.scrollHeight).toBeGreaterThan(height + 500);
+ expect(heading.element().getBoundingClientRect().top).toBeCloseTo(top, 0);
+ expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeGreaterThan(
+ 500,
+ );
+ });
+ });
+
it.each(["delegated", "legacy", "missing"] as const)(
"uses the reported task for %s completion notifications",
async (source) => {
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index 4c7eba620b5d1..e32a7eb485fc6 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -1528,6 +1528,60 @@ describe("deriveMessagesTimelineRows", () => {
expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
});
+ it("keeps the latest finished reply's timestamp while a command is still running", () => {
+ const reply = (streaming: boolean) => ({
+ id: "assistant-reply-entry",
+ kind: "message" as const,
+ createdAt: "2026-01-01T00:00:10Z",
+ message: {
+ id: "assistant-reply" as never,
+ role: "assistant" as const,
+ text: "Training finished, checking memory next.",
+ runId: "turn-1" as never,
+ createdAt: "2026-01-01T00:00:10Z",
+ updatedAt: "2026-01-01T00:00:12Z",
+ streaming,
+ },
+ });
+ const rowsWith = (streaming: boolean) =>
+ deriveMessagesTimelineRows({
+ timelineEntries: [
+ reply(streaming),
+ {
+ id: "command-entry",
+ kind: "work",
+ createdAt: "2026-01-01T00:00:15Z",
+ entry: {
+ id: "command",
+ createdAt: "2026-01-01T00:00:15Z",
+ runId: "turn-1" as never,
+ label: "Running command",
+ command: "nvidia-smi",
+ tone: "tool" as const,
+ toolLifecycleStatus: "inProgress" as const,
+ },
+ },
+ ],
+ latestRun: {
+ runId: "turn-1" as never,
+ status: "running",
+ startedAt: "2026-01-01T00:00:00Z",
+ completedAt: null,
+ },
+ isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ }).find((row) => row.id === "assistant-reply-entry");
+
+ expect(rowsWith(false)).toMatchObject({
+ showAssistantMeta: false,
+ showAssistantTimestamp: true,
+ showAssistantCopyButton: false,
+ });
+ expect(rowsWith(true)).toMatchObject({ showAssistantTimestamp: false });
+ });
+
it("does not show Thinking once the open run's provider turn has ended", () => {
const input = {
timelineEntries: [
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 2fe811db4d18f..8a4a7552901a2 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -591,6 +591,8 @@ type MessagesTimelineRowContent =
message: ChatMessage;
projectedItem?: OrchestrationV2ProjectedTurnItem;
showAssistantMeta: boolean;
+ /** The latest finished reply of a still-running response shows only its timestamp. */
+ showAssistantTimestamp: boolean;
showAssistantCopyButton: boolean;
assistantCopyStreaming: boolean;
assistantTurnDiffSummary?: TurnDiffSummary | undefined;
@@ -2027,10 +2029,10 @@ export function deriveMessagesTimelineRows(input: {
timelineEntry.message.role === "assistant" &&
runIdIsActiveResponse(timelineEntry.message.runId);
- const showAssistantMeta =
+ const isTerminalAssistantMessage =
timelineEntry.message.role === "assistant" &&
- terminalAssistantMessageIds.has(timelineEntry.message.id) &&
- !assistantResponseStillInProgress;
+ terminalAssistantMessageIds.has(timelineEntry.message.id);
+ const showAssistantMeta = isTerminalAssistantMessage && !assistantResponseStillInProgress;
nextRows.push({
kind: "message",
@@ -2041,6 +2043,10 @@ export function deriveMessagesTimelineRows(input: {
? {}
: { projectedItem: timelineEntry.projectedItem }),
showAssistantMeta,
+ showAssistantTimestamp:
+ isTerminalAssistantMessage &&
+ assistantResponseStillInProgress &&
+ !timelineEntry.message.streaming,
showAssistantCopyButton: showAssistantMeta,
assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
assistantTurnDiffSummary:
@@ -2504,6 +2510,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
a.message === bm.message &&
a.projectedItem === bm.projectedItem &&
a.showAssistantMeta === bm.showAssistantMeta &&
+ a.showAssistantTimestamp === bm.showAssistantTimestamp &&
a.showAssistantCopyButton === bm.showAssistantCopyButton &&
a.assistantCopyStreaming === bm.assistantCopyStreaming &&
a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 4c28163766058..cea2daa111207 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -470,13 +470,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
const normalizedFindQuery = findQuery.trim();
const findActive = findExpanded;
- const disclosurePinsEndRef = useRef(false);
- const pinDisclosureToEnd = () => {
- if (!disclosurePinsEndRef.current) return;
- const element = listRef.current?.getScrollableNode();
- if (element) element.scrollTop = element.scrollHeight;
- };
-
const scheduleDisclosureSettle = () => {
if (disclosureSettleFrameRef.current !== null) {
cancelAnimationFrame(disclosureSettleFrameRef.current);
@@ -485,10 +478,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
- pinDisclosureToEnd();
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
- pinDisclosureToEnd();
- disclosurePinsEndRef.current = false;
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
@@ -499,13 +489,11 @@ const ConversationTimeline = memo(function ConversationTimeline({
const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string) => {
disclosureAnchorKeyRef.current = anchorKey;
- disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true;
setDisclosureToggleSettling(true);
scheduleDisclosureSettle();
};
const shouldRestoreVisibleContentPosition = (row: MessagesTimelineRow) => {
- if (disclosurePinsEndRef.current) return false;
const disclosureAnchorKey = disclosureAnchorKeyRef.current;
return disclosureAnchorKey === null || row.id === disclosureAnchorKey;
};
@@ -955,8 +943,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
};
const handleItemSizeChanged = () => {
- if (disclosurePinsEndRef.current) {
- queueMicrotask(pinDisclosureToEnd);
+ if (disclosureAnchorKeyRef.current !== null) {
scheduleDisclosureSettle();
}
scheduleMinimapRefresh();
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index a8f86949bcf38..275e2bff5691b 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -406,7 +406,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
}}
initialFocus={searchInputRef}
align={props.popupAlign ?? "start"}
- collisionAvoidance={docked ? { align: "shift", fallbackAxisSide: "none" } : undefined}
+ // Docked, the popup opens above the composer and slides over it when that space is short,
+ // so a short window gets its full height (Floating UI sizes to the viewport) instead of a sliver.
+ {...(docked
+ ? { side: "top" as const, collisionAvoidance: { side: "shift", align: "shift" } as const }
+ : {})}
className="before:hidden"
padding="none"
touchBackdrop
diff --git a/apps/web/src/components/chat/ProviderSubagentBar.tsx b/apps/web/src/components/chat/ProviderSubagentBar.tsx
index 6aa8799c70b7e..1b74e43284f23 100644
--- a/apps/web/src/components/chat/ProviderSubagentBar.tsx
+++ b/apps/web/src/components/chat/ProviderSubagentBar.tsx
@@ -4,6 +4,7 @@ import {
} from "@t3tools/client-runtime/state/thread-execution";
import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
import { ArrowUpLeftIcon } from "lucide-react";
+import type { ReactNode } from "react";
import { tickTextContent } from "~/lib/tickTextContent";
import type { ProviderInstanceEntry } from "../../providerInstances";
@@ -17,6 +18,7 @@ export function ProviderSubagentBar(props: {
readonly effortLabel: string | null;
readonly status: ProviderSubagentStatus | null;
readonly onOpenParent: (() => void) | null;
+ readonly actions?: ReactNode;
}) {
const { status } = props;
const live = status !== null && isOrchestrationV2WorkActive(status.status);
@@ -64,6 +66,7 @@ export function ProviderSubagentBar(props: {
Runs on its own
+ {props.actions}
{props.onOpenParent ? (
diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx
index 5342bd09b3ca9..368931833dc72 100644
--- a/apps/web/src/components/chat/ThreadActivityBanners.tsx
+++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx
@@ -1,4 +1,5 @@
import type {
+ EnvironmentId,
OrchestrationV2PendingBackgroundTask,
OrchestrationV2ProviderGoal,
ThreadId,
@@ -10,6 +11,7 @@ import {
type RunStopPresentation,
} from "@t3tools/client-runtime/state/thread-execution";
import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads";
+import { commandDisplayText } from "@t3tools/client-runtime/work-log/command-label";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import * as DateTime from "effect/DateTime";
import {
@@ -24,7 +26,6 @@ import {
} from "lucide-react";
import { Fragment } from "react";
import { tickTextContent } from "~/lib/tickTextContent";
-import { cn } from "~/lib/utils";
import { Button, InlineButton } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { ComposerBannerStackItem } from "./ComposerBannerStack";
@@ -34,7 +35,8 @@ import type { ThreadNowItem } from "./ThreadNow";
export function childInputNowItem(input: {
readonly childThreadInputs: ReadonlyArray;
readonly parentAwaitingUser: boolean;
- readonly onOpenThread: (threadId: ThreadId) => void;
+ // A task delegated to a linked environment lives there, not where the parent runs.
+ readonly onOpenThread: (threadId: ThreadId, environmentId: EnvironmentId) => void;
}): (ThreadNowItem & { readonly waitingLabel: string }) | null {
const { childThreadInputs, onOpenThread } = input;
const first = childThreadInputs[0];
@@ -55,7 +57,7 @@ export function childInputNowItem(input: {
{index > 0 ? " " : null}
- onOpenThread(child.id)}>
+ onOpenThread(child.id, child.environmentId)}>
{child.title}
{index < childThreadInputs.length - 1 ? "," : null}
@@ -63,7 +65,7 @@ export function childInputNowItem(input: {
)),
actions: (
- onOpenThread(first.id)}>
+ onOpenThread(first.id, first.environmentId)}>
{first.hasPendingApprovals ? "Open approval" : "Open question"}
),
@@ -172,6 +174,16 @@ export function backgroundWorkNowItem(input: {
const subagent = item?.type === "subagent" ? item : undefined;
return {
task,
+ label:
+ item?.type === "command_execution" && commandDisplayText(item.input) === task.label ? (
+
+ {task.label}
+
+ ) : task.label.includes("\n") ? (
+ {task.label}
+ ) : (
+ task.label
+ ),
activity: subagent?.progress?.replace(/\s+/gu, " ").trim() || null,
startedAtMs: item?.startedAt ? DateTime.toEpochMillis(item.startedAt) : null,
updatedAtMs: subagent ? DateTime.toEpochMillis(subagent.updatedAt) : 0,
@@ -183,34 +195,32 @@ export function backgroundWorkNowItem(input: {
.filter((row) => row.activity)
.toSorted((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
const isCommand = (row: (typeof rows)[number]) => row.task.kind !== "subagent";
- const verb = presentation.waiting ? "Waiting on" : "Running";
const onlyThreadId = only?.childThreadId;
const startedAtMs = only?.startedAtMs ?? null;
return {
id: `background-work:${input.threadId}`,
icon: rows.every(isCommand) ? : ,
- title: only ? (
- <>
- {verb}{" "}
-
- {only.task.label}
-
- >
- ) : (
- presentation.title
- ),
+ title:
+ only && typeof only.label !== "string" && only.task.label !== only.task.kind ? (
+ <>
+ {presentation.waiting ? "Waiting" : "Running"} · {only.label}
+ >
+ ) : (
+ presentation.title
+ ),
description: only ? (
only.activity
) : newest ? (
- `${newest.task.label}: ${newest.activity}`
- ) : rows.every(isCommand) ? (
- {rows.map((row) => row.task.label).join(", ")}
+ <>
+ {newest.label}: {newest.activity}
+ >
) : (
- rows.map((row) => row.task.label).join(", ")
+ rows.map((row, index) => (
+
+ {index > 0 ? ", " : null}
+ {row.label}
+
+ ))
),
actions: (
<>
diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
index ca2497bd20ee8..c9443aeb39293 100644
--- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
+++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
@@ -153,7 +153,7 @@ export function ThreadAutomationsPanel(props: {
}
>
-
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
+ { providerHealthRefreshInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx
new file mode 100644
index 0000000000000..3480e97de3cf7
--- /dev/null
+++ b/apps/web/src/components/settings/IntervalSecondsField.tsx
@@ -0,0 +1,77 @@
+import { useState } from "react";
+import {
+ NumberField,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+} from "../ui/number-field";
+import { normalizeIntervalSeconds } from "./SettingsPanels.logic";
+
+export function IntervalSecondsField({
+ label,
+ value,
+ min,
+ step,
+ onCommit,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ step: number;
+ onCommit: (seconds: number, options: { onFailed: () => void }) => void;
+}) {
+ const [field, setField] = useState({
+ saved: value,
+ draft: value as number | null,
+ committed: value,
+ revision: 0,
+ });
+ if (field.saved !== value) {
+ setField({ ...field, saved: value, draft: value, committed: value });
+ }
+
+ return (
+ setField((current) => ({ ...current, draft }))}
+ onValueCommitted={(next) => {
+ if (next === null) {
+ setField((current) => ({ ...current, draft: current.saved }));
+ return;
+ }
+ const seconds = normalizeIntervalSeconds(next, min);
+ if (seconds === field.committed) {
+ setField((current) => ({ ...current, draft: seconds }));
+ return;
+ }
+ const revision = field.revision + 1;
+ setField((current) => ({ ...current, draft: seconds, committed: seconds, revision }));
+ onCommit(seconds, {
+ onFailed: () =>
+ setField((current) =>
+ current.revision !== revision
+ ? current
+ : {
+ ...current,
+ committed: current.saved,
+ draft: current.draft === seconds ? current.saved : current.draft,
+ },
+ ),
+ });
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 8ffed0a713585..562164e7e85a6 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -82,19 +82,13 @@ import {
EmptyMedia,
EmptyTitle,
} from "../ui/empty";
-import {
- NumberField,
- NumberFieldDecrement,
- NumberFieldGroup,
- NumberFieldIncrement,
- NumberFieldInput,
-} from "../ui/number-field";
import { ScrollArea } from "../ui/scroll-area";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
import { ExpandableText } from "./ExpandableText";
+import { IntervalSecondsField } from "./IntervalSecondsField";
import { ProviderInstanceCard } from "./ProviderInstanceCard";
import { providerClients } from "./providerDriverMeta";
import { searchableSetting } from "./settingsSearch";
@@ -103,7 +97,6 @@ import {
backgroundActivityOverrideSettings,
buildProviderInstanceUpdatePatch,
durationToSeconds,
- normalizeIntervalSeconds,
PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
} from "./SettingsPanels.logic";
import {
@@ -1014,32 +1007,22 @@ export function EnvironmentProviderSettings({
!canWriteSettings && "opacity-50 select-none",
)}
>
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
+ { providerHealthRefreshInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
}
diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
index 73e755c08d48d..d1e4a971509c2 100644
--- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx
+++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
@@ -50,6 +50,7 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
+import { resolvePastedPath } from "@t3tools/shared/path";
import { readEnvironmentScope } from "~/state/session";
import { useSettingsScope } from "./SettingsScopeContext";
import { EnvironmentRow, useEnvironmentTransportLabel } from "./EnvironmentRow";
@@ -1140,7 +1141,10 @@ function ScheduledTaskEditorForm({
onChange={(event) =>
setDraft((current) => ({
...current,
- existingWorktreePath: event.target.value,
+ existingWorktreePath: resolvePastedPath(
+ current.existingWorktreePath,
+ event.target.value,
+ ),
}))
}
/>
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 6b1ddb8a0e8b6..8c8f9d341b8fa 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -18,6 +18,7 @@ import {
SidebarProjectSortOrder,
} from "@t3tools/contracts/settings";
import { createModelSelection } from "@t3tools/shared/model";
+import { resolvePastedPath } from "@t3tools/shared/path";
import * as Equal from "effect/Equal";
import * as Schema from "effect/Schema";
import { HOSTED_APP_CHANNEL } from "../../branding";
@@ -1268,6 +1269,7 @@ export function ThreadsSettingsPanel() {
className="w-full sm:w-72"
value={mixedAddProjectBaseDirectory ? "" : settings.addProjectBaseDirectory}
onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}
+ normalizeDraft={resolvePastedPath}
placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"}
spellCheck={false}
aria-label="Add project base directory"
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx b/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx
new file mode 100644
index 0000000000000..5191b94cf49f8
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsSidebarNav.composition.browser.test.tsx
@@ -0,0 +1,44 @@
+import { expect, it, vi } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+
+import { act, render } from "../../../test/render";
+import { SidebarProvider } from "../ui/sidebar";
+import { SettingsSidebarNav } from "./SettingsSidebarNav";
+
+vi.mock("./useAvailableSettingsSearchItems", () => ({
+ useAvailableSettingsSearchItems: () => [
+ { id: "theme", title: "Theme", to: "/settings/appearance" },
+ ],
+}));
+
+it("ignores composing keys in the search", async () => {
+ await using screen = await render(
+
+
+ ,
+ { router: "/settings/general" },
+ );
+ const search = page.getByRole("combobox", { name: "Search settings" });
+ await search.fill("theme");
+ await expect.element(page.getByRole("option", { name: /Theme/ })).toBeVisible();
+
+ for (const composition of [
+ { key: "Enter", isComposing: true, keyCode: 13 },
+ { key: "Enter", isComposing: false, keyCode: 229 },
+ { key: "Escape", isComposing: true, keyCode: 27 },
+ { key: "Escape", isComposing: false, keyCode: 229 },
+ { key: "ArrowDown", isComposing: true, keyCode: 40 },
+ { key: "ArrowUp", isComposing: true, keyCode: 38 },
+ ]) {
+ const event = new KeyboardEvent("keydown", { ...composition, bubbles: true, cancelable: true });
+ await act(() => search.element().dispatchEvent(event));
+ expect(event.defaultPrevented, JSON.stringify(composition)).toBe(false);
+ expect(screen.router?.state.location.pathname).toBe("/settings/general");
+ await expect.element(search).toHaveValue("theme");
+ }
+
+ await userEvent.keyboard("{Enter}");
+ await vi.waitFor(() =>
+ expect(screen.router?.state.location.pathname).toBe("/settings/appearance"),
+ );
+});
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.tsx b/apps/web/src/components/settings/SettingsSidebarNav.test.tsx
index be48a575b4cda..586c398810a53 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.test.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.test.tsx
@@ -70,7 +70,9 @@ async function search(value: string) {
}
it("opens a matching setting with its anchor and leaves scope retention to the router", async () => {
await search("fast forward");
- await act(async () => input().props.onKeyDown({ key: "Enter", preventDefault() {} }));
+ await act(async () =>
+ input().props.onKeyDown({ key: "Enter", nativeEvent: {}, preventDefault() {} }),
+ );
expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith({
to: "/settings/source-control",
hash: "automatic-pull",
@@ -82,11 +84,18 @@ it("opens a matching setting with its anchor and leaves scope retention to the r
});
it("moves through results with arrow keys and clears search on Escape", async () => {
await search("model");
- await act(async () => input().props.onKeyDown({ key: "ArrowDown", preventDefault() {} }));
+ await act(async () =>
+ input().props.onKeyDown({ key: "ArrowDown", nativeEvent: {}, preventDefault() {} }),
+ );
expect(input().props["aria-activedescendant"]).toBe(
`settings-search-result-${searchSettings("model")[1]!.id}`,
);
- const event = { key: "Escape", preventDefault: vi.fn(), stopPropagation: vi.fn() };
+ const event = {
+ key: "Escape",
+ nativeEvent: {},
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ };
await act(async () => input().props.onKeyDown(event));
expect(event.stopPropagation).toHaveBeenCalledOnce();
expect(input().props.value).toBe("");
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index a0f4dcf5cefdd..a1d5e0cad2630 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -180,6 +180,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
};
const handleSearchKeyDown = (event: KeyboardEvent) => {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Escape" && isSearching) {
event.preventDefault();
event.stopPropagation();
@@ -224,10 +225,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
)}
>
+ {/* Native search inputs clear on Escape even during IME composition. */}
{
setQuery(event.currentTarget.value);
diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx
index e634846447c20..a8b5059de6a79 100644
--- a/apps/web/src/components/settings/StorageSettings.tsx
+++ b/apps/web/src/components/settings/StorageSettings.tsx
@@ -3,6 +3,7 @@ import type {
StorageCleanupSettings,
WorktreeCleanupRules,
} from "@t3tools/contracts";
+import { resolvePastedPath } from "@t3tools/shared/path";
import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
import { useRef, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
@@ -53,6 +54,7 @@ export function WorktreesDirectoryRow() {
const updateSettings = useUpdateScopedSettings();
const mixed = useScopedSettingsMixed(["worktreesDirectory"]);
const edited = useRef(false);
+ const draft = useRef("");
if (
connectedEnvironments.some(
(environment) =>
@@ -87,8 +89,13 @@ export function WorktreesDirectoryRow() {
spellCheck={false}
placeholder={mixed ? "Mixed" : "Default"}
defaultValue={mixed ? "" : settings.worktreesDirectory}
- onChange={() => {
+ onFocus={(event) => {
+ draft.current = event.target.value;
+ }}
+ onChange={(event) => {
edited.current = true;
+ draft.current = resolvePastedPath(draft.current, event.target.value);
+ event.target.value = draft.current;
}}
onBlur={(event) => {
const value = event.target.value.trim();
diff --git a/apps/web/src/components/settings/ThemeColorPicker.tsx b/apps/web/src/components/settings/ThemeColorPicker.tsx
index dead434dc08d4..b9c81e83604be 100644
--- a/apps/web/src/components/settings/ThemeColorPicker.tsx
+++ b/apps/web/src/components/settings/ThemeColorPicker.tsx
@@ -144,7 +144,7 @@ function ThemeColorPickerPanel({
const nextHsv = hexToHsv(nextValue);
setHsv(nextHsv);
setRgbDraft(themeRgbValue(nextValue));
- onChange(nextValue.toLowerCase());
+ onChange(nextValue.toLowerCase() + alphaSuffix);
};
const handleRgbChange = (nextValue: string) => {
diff --git a/apps/web/src/components/settings/colorPickers.browser.test.tsx b/apps/web/src/components/settings/colorPickers.browser.test.tsx
index 101b39321ca08..948845940bc75 100644
--- a/apps/web/src/components/settings/colorPickers.browser.test.tsx
+++ b/apps/web/src/components/settings/colorPickers.browser.test.tsx
@@ -106,6 +106,14 @@ describe("theme color field", () => {
await echo("#ffffff");
expect(slider(HUE).getAttribute("aria-valuenow")).toBe("10");
});
+
+ it("keeps a translucent color's alpha when a hex value is typed", async () => {
+ await using screen = await renderThemeField("#22ccff80");
+ const { onChange } = screen;
+
+ await userEvent.fill(byLabel("Accent color picker hex value"), "#ff0000");
+ expect(onChange).toHaveBeenLastCalledWith("accent", "#ff000080");
+ });
});
async function renderAccentPicker(value: string) {
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index c488a56e21c13..9c05c6043f9fb 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -140,6 +140,21 @@ describe("searchSettings", () => {
expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined();
});
+ it("finds rows by the titles shown on their pages", () => {
+ for (const [title, id] of [
+ ["Automatic worktree cleanup", "storage-worktrees"],
+ ["Delete settled worktrees", "storage-worktrees"],
+ ["Delete old browser artifacts", "storage-artifacts"],
+ ["Delete old rotated logs", "storage-artifacts"],
+ // Coder: no browser settings page, so no "Custom search URL" row.
+ ["Native sessions", "providers"],
+ ["CODEX_HOME path", "providers"],
+ ["Shadow home path", "providers"],
+ ] as const) {
+ expect(searchSettings(title)[0]?.id, title).toBe(id);
+ }
+ });
+
it("keeps catalog result ids unique", () => {
const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 73170871c0f6d..cfa142d5315c6 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -208,7 +208,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/worktrees",
scope: "project-defaults",
searchTerms: [
- "disk storage delete deleted archived threads old inactive merged unchanged worktrees retention days project inherit off custom",
+ "disk storage automatic delete deleted archived threads old inactive merged settled unchanged worktrees retention days project inherit off custom",
],
},
{
@@ -223,7 +223,9 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Artifacts and logs",
to: "/settings/maintenance",
scope: "environment-defaults",
- searchTerms: ["disk storage browser screenshots captures rotated logs cleanup retention"],
+ searchTerms: [
+ "disk storage delete old browser artifacts screenshots captures rotated logs cleanup retention",
+ ],
},
{
id: "project-defaults",
@@ -676,7 +678,7 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Providers",
to: "/settings/providers",
searchTerms: [
- "agents cli codex claude install instances authentication models configuration binary path config directory arguments environment variables display name accent color custom favorite hidden auto compact",
+ "agents cli codex claude install instances authentication models configuration binary path config directory codex_home shadow home arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume",
],
},
{
diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
index 55bc8e996d61c..31ed3a9341a56 100644
--- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
@@ -1192,8 +1192,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{...sortableRootProps}
{...(fileDropHandlers ?? {})}
className={cn(
- "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]",
- metaLine !== null && multiline && "[contain-intrinsic-size:auto_54px]",
+ "list-none [content-visibility:auto] [contain-intrinsic-size:auto_--spacing(9)]",
+ metaLine !== null && multiline && "[contain-intrinsic-size:auto_--spacing(13.5)]",
sortable?.isDragging && "relative z-20",
)}
>
diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
index 9cf7dc9e2ef53..6a85013ea911f 100644
--- a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
@@ -1,4 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
+import { Undo2Icon } from "lucide-react";
import { useState } from "react";
import {
@@ -29,8 +30,11 @@ export function SidebarThreadUndoNotice() {
if (!text) return null;
return (
- // No min-w-0: the undo button sets this item's minimum, so a shared row squeezes its neighbour.
-
+
{
if (!summary && event.target === event.currentTarget) setShownSummary(null);
@@ -43,7 +47,14 @@ export function SidebarThreadUndoNotice() {
!summary && "opacity-0",
)}
>
- {threadUndoLabel(keybindings)}
+
+ {/* The icon's negative margin cancels its width, so beside the label it sits in
+ clipped space; once the label is too wide it wraps away and the icon shows. */}
+
+
+ {threadUndoLabel(keybindings)}
+
+
{text},
diff --git a/apps/web/src/components/ui/draft-input.tsx b/apps/web/src/components/ui/draft-input.tsx
index c0109b9a69852..39cc4c83669a8 100644
--- a/apps/web/src/components/ui/draft-input.tsx
+++ b/apps/web/src/components/ui/draft-input.tsx
@@ -4,10 +4,11 @@ import { Input, type InputProps } from "./input";
export type DraftInputProps = Omit & {
readonly value: string;
readonly onCommit: (next: string) => void;
+ readonly normalizeDraft?: (previous: string, next: string) => string;
};
/** Commit drafts without settings-wide rerenders or server round trips per keystroke. */
-export function DraftInput({ value, onCommit, ...rest }: DraftInputProps) {
- const bag = useCommitOnBlur(value, onCommit);
+export function DraftInput({ value, onCommit, normalizeDraft, ...rest }: DraftInputProps) {
+ const bag = useCommitOnBlur(value, onCommit, normalizeDraft);
return ;
}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 2261b1a8f54c3..788c8a1043def 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -78,7 +78,7 @@ function PopoverPopup({
collisionAvoidance={collisionAvoidance}
collisionPadding={variant === "panel" ? 0 : undefined}
className={cn(
- "h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none",
+ "h-(--positioner-height) max-h-(--available-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none",
variant === "panel"
? "z-(--z-sheet) w-[min(calc(var(--thread-details-panel-width)+--spacing(4)),calc(var(--anchor-width)---spacing(2)))] transition-[top] duration-150 ease-out data-instant:transition-[top] motion-reduce:transition-none motion-reduce:data-instant:transition-none"
: "z-[130]",
diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts
index ab7f2c542a19f..77383df61e8f7 100644
--- a/apps/web/src/composer-rich-text-doc.test.ts
+++ b/apps/web/src/composer-rich-text-doc.test.ts
@@ -1089,9 +1089,19 @@ describe("inline code entry", () => {
type(editor, "`");
expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]);
expect(editor.state.doc.textContent).toBe("`beforeafter`");
+
+ const local = editorFor();
+ type(local, "a ` ");
+ local.commands.insertContent({ type: nodeType });
+ type(local, "`local`");
+ expect(local.state.doc.firstChild?.lastChild?.toJSON()).toEqual({
+ type: "text",
+ marks: [{ type: "code" }],
+ text: "local",
+ });
});
- it.each(["", "prefix ", "@README.md"])(
+ it.each(["", "prefix ", "@README.md", "a ``` "])(
"formats a filled pair after %j, leaves following text plain, and keeps the normal input rule",
(prefix) => {
const editor = editorFor();
@@ -1129,6 +1139,48 @@ describe("inline code entry", () => {
},
);
+ it.each([
+ [
+ "formats a pair",
+ "do ",
+ [
+ { type: "text", text: "do " },
+ { type: "text", marks: [{ type: "code" }], text: "xyz" },
+ ],
+ ],
+ [
+ "keeps a pair literal after an unmatched backtick",
+ "a ` ",
+ [{ type: "text", text: "a ` `xyz`" }],
+ ],
+ ])("%s whose backticks came from a dead key", (_name, prefix, content) => {
+ const editor = editorFor();
+ type(editor, prefix);
+ editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 1));
+ editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 2));
+ editor.commands.setTextSelection(prefix.length + 2);
+ type(editor, "xyz");
+ expect(editor.state.doc.textContent).toBe(`${prefix}\`xyz\``);
+
+ editor.commands.setTextSelection(prefix.length + 6);
+ expect(editor.state.doc.firstChild?.toJSON().content).toEqual(content);
+ });
+
+ it("does not reuse the closing backtick of a literal pair to open code", () => {
+ const editor = editorFor();
+ editor.view.dispatch(editor.state.tr.insertText("is `enabled`", 1));
+ type(editor, " and `");
+ expect(editor.state.doc.firstChild?.toJSON().content).toEqual([
+ { type: "text", text: "is `enabled` and `" },
+ ]);
+
+ type(editor, "persist`");
+ expect(editor.state.doc.firstChild?.toJSON().content).toEqual([
+ { type: "text", text: "is `enabled` and " },
+ { type: "text", marks: [{ type: "code" }], text: "persist" },
+ ]);
+ });
+
it("keeps composition edits literal until the caret leaves the typed pair", () => {
const editor = editorFor();
type(editor, "``");
diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts
index 7025b687850da..ae0c9441625b4 100644
--- a/apps/web/src/composer-rich-text-doc.ts
+++ b/apps/web/src/composer-rich-text-doc.ts
@@ -38,6 +38,7 @@ import {
TextSelection,
type Transaction,
} from "@tiptap/pm/state";
+import { ReplaceStep } from "@tiptap/pm/transform";
import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
import { nextOrderedMarkerText } from "~/composer-list-continuation";
@@ -80,7 +81,7 @@ function codePairCaretPlugin(type: MarkType) {
const to = tr.mapping.mapResult(pair.to, -1);
return from.deleted || to.deleted ? [] : [{ from: from.pos, to: to.pos }];
});
- const typed = tr.getMeta(codePairKey) as CodePair | undefined;
+ const typed = (tr.getMeta(codePairKey) as CodePair | undefined) ?? composedCodePair(tr);
return typed ? [...mapped, typed] : mapped;
},
},
@@ -90,7 +91,7 @@ function codePairCaretPlugin(type: MarkType) {
const $from = view.state.doc.resolve(from);
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return false;
const before = $from.nodeBefore?.text ?? "";
- if (/(^|[^`])`$/.test(before)) {
+ if (closesBacktickPair($from)) {
view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 }));
return true;
}
@@ -151,6 +152,45 @@ function codePairCaretPlugin(type: MarkType) {
});
}
+/**
+ * Whether a backtick typed at `$pos` would complete an empty `` pair. The
+ * backtick before `$pos` only opens one when earlier backticks are all paired.
+ */
+function closesBacktickPair($pos: ResolvedPos): boolean {
+ return (
+ $pos.parent.isTextblock &&
+ !$pos.parent.type.spec.code &&
+ /(^|[^`])`$/.test($pos.nodeBefore?.text ?? "") &&
+ literalBackticksBefore($pos) % 2 === 1
+ );
+}
+
+/**
+ * Layouts where backtick is a dead key type it through composition, and the
+ * pair handler above skips composing input. Read the pair
+ * from the composed insert instead; dispatching mid-composition would break it.
+ */
+function composedCodePair(tr: Transaction): CodePair | undefined {
+ if (tr.getMeta("composition") === undefined || tr.steps.length !== 1) {
+ return undefined;
+ }
+
+ const step = tr.steps[0];
+ if (!(step instanceof ReplaceStep) || step.from !== step.to || step.slice.size !== 1) {
+ return undefined;
+ }
+
+ if (step.slice.content.firstChild?.text !== "`") {
+ return undefined;
+ }
+
+ if (!closesBacktickPair(tr.before.resolve(step.from))) {
+ return undefined;
+ }
+
+ return { from: step.from - 1, to: step.from + 1 };
+}
+
/**
* Tiptap's code mark excludes every other mark, which rejects the `bold+code`
* spans markdown like `**\`x\`**` parses into and drops the whole insert.
@@ -168,8 +208,44 @@ export const ComposerCodeExtension = Code.extend({
addProseMirrorPlugins() {
return [...(this.parent?.() ?? []), codePairCaretPlugin(this.type)];
},
+ // Tiptap's rule reads back from the caret, so after a literal `enabled` the
+ // next typed backtick would reuse its closing backtick and wrap ` and ` in
+ // code. Backticks pair left to right, so skip an opener that closes a pair.
+ addInputRules() {
+ return (this.parent?.() ?? []).map(
+ (rule) =>
+ new InputRule({
+ find: rule.find,
+ undoable: rule.undoable,
+ handler: (props) => {
+ if (literalBackticksBefore(props.state.doc.resolve(props.range.from)) % 2 === 1) {
+ return null;
+ }
+
+ return rule.handler(props);
+ },
+ }),
+ );
+ },
});
+/** Counts backticks typed as plain text before `$pos` in its textblock; code text never pairs. */
+function literalBackticksBefore($pos: ResolvedPos): number {
+ let count = 0;
+ $pos.parent.forEach((node, offset) => {
+ if (offset >= $pos.parentOffset) return;
+ if (!node.isText) {
+ count = 0;
+ return;
+ }
+ if (node.marks.some((mark) => mark.type.spec.code)) return;
+
+ const text = node.text!.slice(0, $pos.parentOffset - offset);
+ count += [...text.matchAll(/(?>;
+ readonly docLength: number;
+ readonly contextIds: ReadonlyArray;
}
function readAtomSource(node: ProseMirrorNode): string {
@@ -1354,7 +1431,20 @@ export function serializeSelection(doc: ProseMirrorNode, from: number, to: numbe
return serializeEditorDoc(doc.type.create(null, content)).value;
}
+// Docs are immutable, and one keystroke serializes the same doc from the marker
+// plugin, onUpdate, and onSelectionUpdate.
+const serializedDocs = new WeakMap();
+
export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
+ let map = serializedDocs.get(doc);
+ if (!map) {
+ map = serializeDoc(doc);
+ serializedDocs.set(doc, map);
+ }
+ return map;
+}
+
+function serializeDoc(doc: ProseMirrorNode): RichDocMap {
const acc: RichAccumulator = { runs: [], value: "", flat: 0, md: 0 };
const blocks: ProseMirrorNode[] = [];
doc.content.forEach((node) => {
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index e00b782d86e0e..a4550b62cda49 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -1266,6 +1266,7 @@ function modelReconciliationHarness(
const shells = createEnvironmentThreadShellAtoms({
catalogValueAtom,
snapshotAtom: () => snapshotAtom,
+ hiddenThreadTagsAtom: () => Atom.make(null),
});
const selectedShell = shells.threadShellAtom({ environmentId, threadId: selectedThreadId });
onTestFinished(registry.mount(selectedShell));
diff --git a/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx
index cec4f8cb8ea5b..507ecbbc32410 100644
--- a/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx
+++ b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
+import { resolvePastedPath } from "@t3tools/shared/path";
+
import { render } from "../../test/render";
import { DraftInput } from "../components/ui/draft-input";
@@ -38,4 +40,23 @@ describe("settings draft commits", () => {
expect(saved).toBe("/projects");
});
+
+ it("normalizes each change against the draft before it", async () => {
+ let saved = "/mnt/old/";
+ const commit = (next: string) => {
+ saved = next;
+ };
+ await using screen = await render(
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ await input.fill("/mnt/old//mnt/new");
+ await userEvent.tab();
+ expect(saved).toBe("/mnt/new");
+
+ await input.fill("");
+ await userEvent.type(input, "/tmp/a\\ b");
+ await userEvent.tab();
+ expect(saved).toBe("/tmp/a\\ b");
+ });
});
diff --git a/apps/web/src/hooks/useCommitOnBlur.ts b/apps/web/src/hooks/useCommitOnBlur.ts
index 022f79a2a58bb..6ad8f530989cf 100644
--- a/apps/web/src/hooks/useCommitOnBlur.ts
+++ b/apps/web/src/hooks/useCommitOnBlur.ts
@@ -1,13 +1,18 @@
import { type ChangeEvent, type KeyboardEvent, useRef, useState } from "react";
-export function useCommitOnBlur(value: string, onCommit: (next: string) => void) {
+export function useCommitOnBlur(
+ value: string,
+ onCommit: (next: string) => void,
+ normalizeDraft?: (previous: string, next: string) => string,
+) {
const [draft, setDraft] = useState(null);
const valueOnFocus = useRef(value);
return {
value: draft ?? value,
onChange: (event: ChangeEvent) => {
- setDraft(event.target.value);
+ const next = event.target.value;
+ setDraft(normalizeDraft ? normalizeDraft(draft ?? value, next) : next);
},
onFocus: () => {
valueOnFocus.current = value;
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 0ca71e34bc491..51e9a426ad187 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -614,8 +614,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
reportFailure: false,
});
const { environments } = useEnvironments();
- const persistServerSettings = async (request: Parameters[0]) => {
+ const persistServerSettings = async (
+ request: Parameters[0],
+ onFailed?: () => void,
+ ) => {
const result = await persist(request);
+ if (result._tag === "Failure") onFailed?.();
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const label =
environments.find((environment) => environment.environmentId === request.environmentId)
@@ -630,7 +634,10 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
return result;
};
const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
- const updateSettings = (patch: UnifiedSettingsPatch) => {
+ const updateSettings = (
+ patch: UnifiedSettingsPatch,
+ options?: { readonly onFailed?: () => void },
+ ) => {
const { serverPatch, clientPatch } = splitPatch(patch);
const canWriteServerPatch =
@@ -639,6 +646,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
readEnvironmentScope(environmentId, scope),
);
if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) {
+ options?.onFailed?.();
toastManager.add({
type: "warning",
title: "Setting not saved",
@@ -648,18 +656,20 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
// Dropping the write silently leaves the control looking saved.
- const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
- toastManager.add({
+ const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => {
+ options?.onFailed?.();
+ return toastManager.add({
type: "warning",
title: "Setting not saved",
description,
});
+ };
if (Object.keys(localPatch).length > 0) {
if (environmentId) {
- void persistServerSettings({
- environmentId,
- input: { patch: localPatch },
- });
+ void persistServerSettings(
+ { environmentId, input: { patch: localPatch } },
+ options?.onFailed,
+ );
} else {
warnUnsaved();
}
@@ -712,6 +722,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
result: await persist(request),
})),
).then((outcomes) => {
+ if (
+ deniedLabels.length > 0 ||
+ outcomes.some(({ result }) => result._tag === "Failure")
+ ) {
+ options?.onFailed?.();
+ }
const failures = outcomes.flatMap(({ label, result }) => {
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return [];
const error = squashAtomCommandFailure(result);
diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts
index 7e05591e58ff3..d0a65547423e0 100644
--- a/apps/web/src/hooks/useThreadActions.permissions.test.ts
+++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts
@@ -10,6 +10,10 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+import { useDiffPanelStore } from "../diffPanelStore";
+import { useRightPanelStore } from "../rightPanelStore";
+
function deferred() {
let resolve!: (value: T) => void;
const promise = new Promise((complete) => {
@@ -163,6 +167,22 @@ vi.mock("./useSettings", () => ({
import { useThreadActions } from "./useThreadActions";
+function seedThreadPanelState(ref: ScopedThreadRef) {
+ useRightPanelStore.getState().show(ref);
+ useDiffPanelStore.getState().selectGitScope(ref, "branch");
+}
+
+function threadPanelStatePresence(ref: ScopedThreadRef) {
+ const key = scopedThreadKey(ref);
+ return {
+ rightPanel: key in useRightPanelStore.getState().byThreadKey,
+ diffPanel: key in useDiffPanelStore.getState().byThreadKey,
+ };
+}
+// Coder: no browser preview, so there is no mini-player state to seed or clear.
+const panelStateKept = { rightPanel: true, diffPanel: true };
+const panelStateCleared = { rightPanel: false, diffPanel: false };
+
const primary = EnvironmentId.make("primary");
const secondary = EnvironmentId.make("secondary");
const target = { environmentId: secondary, threadId: ThreadId.make("thread") };
@@ -226,6 +246,8 @@ beforeEach(() => {
state.toasts = [];
state.afterRequest = undefined;
state.sessionLookupFails = false;
+ useRightPanelStore.setState(useRightPanelStore.getInitialState(), true);
+ useDiffPanelStore.setState(useDiffPanelStore.getInitialState(), true);
});
describe("thread action permissions", () => {
@@ -284,23 +306,28 @@ describe("thread action permissions", () => {
it("deletes an archived thread only with its own environment's grant", async () => {
state.threads = [];
+ seedThreadPanelState(target);
const actions = useThreadActions();
await actions.deleteThread(target);
expect(state.requests).toEqual([]);
+ expect(threadPanelStatePresence(target)).toEqual(panelStateKept);
state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
expect((await actions.deleteThread(target))._tag).toBe("Success");
expect(state.requests).toEqual([
expect.objectContaining({ action: "delete", environmentId: secondary }),
]);
+ expect(threadPanelStatePresence(target)).toEqual(panelStateCleared);
});
it("stops before delete and local cleanup when permission is revoked during session stop", async () => {
state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
state.threads[0]!.session = { status: "ready" };
state.afterRequest = () => state.scopes.get(secondary)!.clear();
+ seedThreadPanelState(target);
expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure");
expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]);
expect(state.localEffects).toEqual([]);
+ expect(threadPanelStatePresence(target)).toEqual(panelStateKept);
});
// Coder: worktree removal needs no source-control grant, so it is always offered.
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index 836a8c660a6bc..307498bba36fe 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -44,6 +44,8 @@ import {
readThreadShells,
} from "../state/entities";
import { useUiStateStore } from "../uiStateStore";
+import { useDiffPanelStore } from "../diffPanelStore";
+import { useRightPanelStore } from "../rightPanelStore";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
@@ -234,6 +236,12 @@ function expireThreadUndos(target: ScopedThreadRef, kinds: readonly string[]) {
for (const kind of kinds) ThreadUndo.invalidate(kind, scopedThreadKey(target));
}
+// Coder: no browser preview, so there is no preview or mini-player state to clear.
+function clearDeletedThreadPanelState(ref: ScopedThreadRef) {
+ useRightPanelStore.getState().removeThread(ref);
+ useDiffPanelStore.getState().removeThread(ref);
+}
+
/** A No project thread that already has its own folder, on a server that can promote it. */
export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean {
const thread = readThreadShell(threadRef);
@@ -432,6 +440,7 @@ export function useThreadActions() {
if (result._tag === "Success") {
expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]);
refreshArchivedThreadsForEnvironment(target.environmentId);
+ clearDeletedThreadPanelState(target);
}
return result;
}
@@ -553,6 +562,7 @@ export function useThreadActions() {
threadRef,
);
clearTerminalUiState(threadRef);
+ clearDeletedThreadPanelState(threadRef);
if (shouldNavigateToFallback) {
const fallbackThread = fallbackThreadId
diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts
index 18c1b4c53c5a9..fc96aa1f85896 100644
--- a/apps/web/src/keybindings.ts
+++ b/apps/web/src/keybindings.ts
@@ -15,15 +15,20 @@ import {
matchesKeybindingShortcutModifiers,
normalizeEventKey,
resolveEventKeys,
- type ShortcutEventLike,
+ type ShortcutEventLike as SharedShortcutEventLike,
type ShortcutModifierStateLike,
} from "@t3tools/shared/keybindings";
import { isElectron } from "./env";
+import { isEditableFocused } from "./lib/editableFocus";
import { projectScriptIdFromCommand } from "./projectScripts";
-export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
+export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
+export interface ShortcutEventLike extends SharedShortcutEventLike {
+ target?: EventTarget | null;
+}
+
export interface ShortcutMatchContext {
terminalFocus: boolean;
terminalOpen: boolean;
@@ -177,6 +182,9 @@ export function resolveShortcutCommand(
): KeybindingCommand | null {
const platform = resolvePlatform(options);
const context = resolveContext(options);
+ if (options?.context?.editableFocus === undefined && typeof document !== "undefined") {
+ context.editableFocus = isEditableFocused(event.target ?? document.activeElement);
+ }
for (let index = keybindings.length - 1; index >= 0; index -= 1) {
const binding = keybindings[index];
diff --git a/apps/web/src/keybindingsFocus.test.ts b/apps/web/src/keybindingsFocus.test.ts
new file mode 100644
index 0000000000000..34be53f1709e9
--- /dev/null
+++ b/apps/web/src/keybindingsFocus.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+import type { KeybindingCommand } from "@t3tools/contracts";
+import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings";
+import { resolveShortcutCommand } from "./keybindings";
+import * as editableFocus from "./lib/editableFocus";
+
+function setupFocus() {
+ const editor = new EventTarget();
+ const other = new EventTarget();
+ vi.stubGlobal("document", { activeElement: editor });
+ const focus = vi
+ .spyOn(editableFocus, "isEditableFocused")
+ .mockImplementation((target) => target === editor);
+ return {
+ editor,
+ other,
+ [Symbol.dispose]() {
+ focus.mockRestore();
+ vi.unstubAllGlobals();
+ },
+ };
+}
+
+function keydown(target?: EventTarget) {
+ return {
+ key: "k",
+ ctrlKey: true,
+ metaKey: false,
+ altKey: false,
+ shiftKey: false,
+ ...(target ? { target } : {}),
+ };
+}
+
+function bindings(command: KeybindingCommand, when = "!editableFocus") {
+ return compileResolvedKeybindingsConfig([{ key: "mod+k", command, when }]);
+}
+
+describe("editable shortcut conditions", () => {
+ it.each(["commandPalette.toggle", "sidebar.toggle", "thread.next", "thread.jump.1"] as const)(
+ "honors !editableFocus for %s without a caller-provided focus context",
+ (command) => {
+ using focus = setupFocus();
+ const rules = bindings(command);
+
+ expect(
+ resolveShortcutCommand(keydown(focus.editor), rules, {
+ platform: "Linux",
+ context: { terminalFocus: false },
+ }),
+ ).toBeNull();
+ expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBe(
+ command,
+ );
+ },
+ );
+
+ it("uses the active element when a forwarded shortcut has no target", () => {
+ using _focus = setupFocus();
+ expect(
+ resolveShortcutCommand(keydown(), bindings("commandPalette.toggle"), { platform: "Linux" }),
+ ).toBeNull();
+ });
+
+ it("honors a positive editableFocus condition", () => {
+ using focus = setupFocus();
+ const rules = bindings("commandPalette.toggle", "editableFocus");
+ expect(resolveShortcutCommand(keydown(focus.editor), rules, { platform: "Linux" })).toBe(
+ "commandPalette.toggle",
+ );
+ expect(resolveShortcutCommand(keydown(focus.other), rules, { platform: "Linux" })).toBeNull();
+ });
+
+ it("preserves explicit focus overrides", () => {
+ using focus = setupFocus();
+ const rules = bindings("commandPalette.toggle");
+
+ expect(
+ resolveShortcutCommand(keydown(focus.editor), rules, {
+ platform: "Linux",
+ context: { editableFocus: false },
+ }),
+ ).toBe("commandPalette.toggle");
+ expect(
+ resolveShortcutCommand(keydown(focus.other), rules, {
+ platform: "Linux",
+ context: { editableFocus: true },
+ }),
+ ).toBeNull();
+ });
+
+ it("keeps shortcuts without an editable-focus condition active while editing", () => {
+ using focus = setupFocus();
+ expect(
+ resolveShortcutCommand(keydown(focus.editor), bindings("commandPalette.toggle", "true"), {
+ platform: "Linux",
+ }),
+ ).toBe("commandPalette.toggle");
+ });
+});
diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts
index a84e072255b5b..fbd17226cefc8 100644
--- a/apps/web/src/lib/archivedThreadsState.ts
+++ b/apps/web/src/lib/archivedThreadsState.ts
@@ -7,6 +7,8 @@ import {
import type { EnvironmentId } from "@t3tools/contracts";
import { orchestrationEnvironment } from "../state/orchestration";
+import { serverEnvironment } from "../state/server";
+import { environmentThreadShells } from "../state/threads";
import { appAtomRegistry } from "../rpc/atomRegistry";
function archivedSnapshotAtom(environmentId: EnvironmentId) {
@@ -18,6 +20,10 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) {
const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
getSnapshotAtom: archivedSnapshotAtom,
+ hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom,
+ hiddenThreadKeysAtom: environmentThreadShells.hiddenThreadKeysAtom,
+ liveThreadRefsAtom: environmentThreadShells.threadRefsAtom,
+ anyEnvironmentHidingAtom: environmentThreadShells.anyEnvironmentHidingAtom,
labelPrefix: "web:archived-thread-snapshots",
});
diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts
index 582dba9569a8a..bf792a803238c 100644
--- a/apps/web/src/lib/turnDiffTree.test.ts
+++ b/apps/web/src/lib/turnDiffTree.test.ts
@@ -87,6 +87,21 @@ describe("buildTurnDiffTree", () => {
});
it("compacts only single-directory chains and stops at branch points", () => {
+ expect(
+ buildTurnDiffTree([
+ { path: "src/a/b/first.ts", kind: "modified", additions: 1, deletions: 0 },
+ { path: "src/c/d/second.ts", kind: "modified", additions: 2, deletions: 0 },
+ ]),
+ ).toMatchObject([
+ {
+ name: "src",
+ children: [
+ { name: "a/b", path: "src/a/b", children: [{ name: "first.ts" }] },
+ { name: "c/d", path: "src/c/d", children: [{ name: "second.ts" }] },
+ ],
+ },
+ ]);
+
const tree = buildTurnDiffTree([
{ path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 },
{ path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 },
diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts
index e841e18ce24d2..7d491e15ccfb5 100644
--- a/apps/web/src/lib/turnDiffTree.ts
+++ b/apps/web/src/lib/turnDiffTree.ts
@@ -56,14 +56,7 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null {
}
function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode {
- const compactedChildren = node.children.map((child) =>
- child.kind === "directory" ? compactDirectoryNode(child) : child,
- );
-
- let compactedNode: TurnDiffTreeDirectoryNode = {
- ...node,
- children: compactedChildren,
- };
+ let compactedNode = node;
while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") {
const onlyChild = compactedNode.children[0];
diff --git a/apps/web/src/lib/workspaceFocus.browser.test.ts b/apps/web/src/lib/workspaceFocus.browser.test.ts
index f0064630f08c2..f97ac2e5408d3 100644
--- a/apps/web/src/lib/workspaceFocus.browser.test.ts
+++ b/apps/web/src/lib/workspaceFocus.browser.test.ts
@@ -226,6 +226,29 @@ describe("workspace area focus", () => {
await vi.waitFor(() => expect(document.activeElement).toBe(composer.button));
});
+ it("keeps focus on the control a panel tab switch came from", () => {
+ using fixture = setup();
+ const panel = fixture.area("panel", 3);
+ const content = document.createElement("div");
+ content.setAttribute("data-right-panel-surface-content", "");
+ const row = (name: string) => {
+ const button = document.createElement("button");
+ button.textContent = name;
+ content.append(button);
+ return button;
+ };
+ const earlierRow = row("reference-analysis.md");
+ const clickedRow = row("print-guide.md");
+ panel.element.append(content);
+ panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md";
+ earlierRow.focus();
+ panel.element.dataset.workspaceFocusKey = "panel:file:reference-analysis.md";
+ clickedRow.focus();
+ panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md";
+ expect(fixture.controller.focus("panel")).toBe(true);
+ expect(document.activeElement).toBe(clickedRow);
+ });
+
it("waits for a requested area to mount", async () => {
using fixture = setup();
fixture.area("composer", 2);
diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts
index e612a8ff54974..dcdb8ffdabd89 100644
--- a/apps/web/src/lib/workspaceFocus.ts
+++ b/apps/web/src/lib/workspaceFocus.ts
@@ -186,6 +186,7 @@ export function createWorkspaceFocusController(doc: Document) {
// A lazy surface's Suspense fallback is empty. Wait for its content instead
// of claiming focus on the tab strip before the useful controls mount.
if (surface && !surface.hasChildNodes()) return false;
+ if (!selector && surface?.contains(doc.activeElement)) return true;
if (selector) {
const target = area.querySelector(selector);
if (!target || !isWorkspaceElementVisible(target)) return false;
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index 6d9c692d64564..1f736d4f119ae 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -166,6 +166,8 @@ interface RightPanelStoreState {
activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
+ /** Agent-driven activation. Not a user action, so it does not move keyboard focus. */
+ revealSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
@@ -451,6 +453,11 @@ const userActionOnThread = (
: {}),
});
+const activated = (current: ThreadRightPanelState, surfaceId: string): ThreadRightPanelState =>
+ current.surfaces.some((surface) => surface.id === surfaceId)
+ ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
+ : current;
+
const closeAction = (
state: RightPanelStoreState,
threadKey: string,
@@ -928,11 +935,11 @@ export const useRightPanelStore = create()(
),
activateSurface: (ref, surfaceId) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) =>
- current.surfaces.some((surface) => surface.id === surfaceId)
- ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
- : current,
- ),
+ userAction(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
+ ),
+ revealSurface: (ref, surfaceId) =>
+ set((state) =>
+ automaticUpdate(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
),
closeSurface: (ref, surfaceId) =>
set((state) =>
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index c7e5e06151717..11241fffee644 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -18,7 +18,7 @@ import {
readProject,
useAllEnvironmentShellsBootstrapped,
useProjects,
- useThreadShells,
+ useVisibleThreadShells,
} from "../state/entities";
import { useEnvironments } from "../state/environments";
@@ -28,7 +28,7 @@ function ChatIndexRouteView() {
function IndexDraftLanding() {
const projects = useProjects();
- const threads = useThreadShells();
+ const threads = useVisibleThreadShells();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const { config } = useCoder();
const { environments } = useEnvironments();
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index c85e4e6f066a4..931694ba00231 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -115,6 +115,23 @@ export function useThreadShells(enabled = true): ReadonlyArray {
+ return useAtomValue(environmentThreadShells.hiddenThreadKeysAtom);
+}
+
+/** Thread shells minus those the tag filter hides, for lists that show threads to the user. */
+export function useVisibleThreadShells(enabled = true): ReadonlyArray {
+ return useAtomValue(
+ enabled ? environmentThreadShells.visibleThreadShellsAtom : EMPTY_THREAD_SHELLS_ATOM,
+ );
+}
+
export function useAllEnvironmentShellsBootstrapped(): boolean {
return useAtomValue(allEnvironmentShellsBootstrappedAtom);
}
diff --git a/apps/web/src/state/olderSettledShelf.browser.test.tsx b/apps/web/src/state/olderSettledShelf.browser.test.tsx
new file mode 100644
index 0000000000000..a99ba97e480b6
--- /dev/null
+++ b/apps/web/src/state/olderSettledShelf.browser.test.tsx
@@ -0,0 +1,39 @@
+import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { render } from "../../test/render";
+import { useOlderSettledShelf } from "./olderSettledShelf";
+
+const environmentId = EnvironmentId.make("env-a");
+const first = ProjectId.make("project-a");
+const second = ProjectId.make("project-b");
+
+function Shelf(props: { readonly scopes: ReadonlyArray }) {
+ const shelf = useOlderSettledShelf({ scopes: props.scopes, expanded: true, visibleCount: 0 });
+ return (
+ <>
+ {shelf.pages.get(environmentId) ?? 0}
+ shelf.grow(new Map([[environmentId, 1]]))}>
+ Grow
+
+ >
+ );
+}
+
+describe("useOlderSettledShelf", () => {
+ it("keeps requested pages when the scope's projects reorder", async () => {
+ await using screen = await render(
+ ,
+ // A bare hook host; nothing here links or navigates.
+ { router: false },
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Grow" }));
+ await expect.element(screen.getByRole("status")).toHaveTextContent("1");
+
+ // Loading a page can reorder the sidebar's projects; the shelf must not drop its pages.
+ await screen.rerender( );
+ await expect.element(screen.getByRole("status")).toHaveTextContent("1");
+ });
+});
diff --git a/apps/web/src/state/olderSettledShelf.ts b/apps/web/src/state/olderSettledShelf.ts
index a903fd6d99b23..e313c805dd3cc 100644
--- a/apps/web/src/state/olderSettledShelf.ts
+++ b/apps/web/src/state/olderSettledShelf.ts
@@ -3,6 +3,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/model
import {
isListedSettledThread,
settledShelfFrontier,
+ sortOlderSettledScopes,
unloadedOlderSettledCount,
wantedOlderSettledPages,
type OlderSettledScope,
@@ -27,10 +28,11 @@ export function useOlderSettledShelf(input: {
/** Rows the shelf shows; pages are read until it lists that many. */
readonly visibleCount: number;
}) {
- const scopeKey = JSON.stringify(input.scopes);
+ const scopes = sortOlderSettledScopes(input.scopes);
+ const scopeKey = JSON.stringify(scopes);
const [requested, setRequested] = useState({ scopeKey, pages: NO_PAGES });
const pages = requested.scopeKey === scopeKey && input.expanded ? requested.pages : NO_PAGES;
- const shelfScopes = input.scopes.map((scope) => ({
+ const shelfScopes = scopes.map((scope) => ({
scope,
pages: pages.get(scope.environmentId) ?? 0,
}));
@@ -67,7 +69,7 @@ export function useOlderSettledShelf(input: {
isListed: (thread: EnvironmentThreadShell) => isListedSettledThread(thread.source, frontier),
/** Older settled threads in scope that are not local; add it to the local Settled rows. */
unloadedCount: (threads: ReadonlyArray) =>
- unloadedOlderSettledCount({ olderSettled: olderOf, scopes: input.scopes, threads }),
+ unloadedOlderSettledCount({ olderSettled: olderOf, scopes, threads }),
loading: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "pending"),
failed: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "failed"),
retry: () => {
diff --git a/apps/web/src/state/terminalSessions.test.ts b/apps/web/src/state/terminalSessions.test.ts
index 14a871aeebdd6..e9ac75f8dfae7 100644
--- a/apps/web/src/state/terminalSessions.test.ts
+++ b/apps/web/src/state/terminalSessions.test.ts
@@ -1,11 +1,23 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts";
import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal";
-import { AtomRegistry } from "effect/reactivity";
+import * as Cause from "effect/Cause";
+import * as Option from "effect/Option";
+import { AsyncResult, AtomRegistry } from "effect/reactivity";
-import { attachedTerminalSessionAtom, selectKnownTerminalSessions } from "./terminalSessions";
+import {
+ attachedTerminalSessionAtom,
+ knownTerminalSessionsAtom,
+ selectKnownTerminalSessions,
+} from "./terminalSessions";
const attachCalls = vi.hoisted(() => [] as unknown[]);
+const metadataAtom = await vi.hoisted(async () => {
+ const { AsyncResult, Atom } = await import("effect/reactivity");
+ return Atom.make>>(
+ AsyncResult.initial(true),
+ );
+});
vi.mock("./terminal", async () => {
const { AsyncResult, Atom } = await import("effect/reactivity");
return {
@@ -14,7 +26,7 @@ vi.mock("./terminal", async () => {
attachCalls.push(request);
return Atom.make(AsyncResult.initial(true));
},
- metadata: () => Atom.make(AsyncResult.initial(true)),
+ metadata: () => metadataAtom,
},
};
});
@@ -96,6 +108,37 @@ describe("selectKnownTerminalSessions", () => {
});
});
+describe("knownTerminalSessionsAtom", () => {
+ const metadataResult = (...terminals: ReadonlyArray) =>
+ AsyncResult.success>(terminals);
+
+ it("notifies only when the thread's own terminals change", () => {
+ const registry = AtomRegistry.make();
+ const sessions = knownTerminalSessionsAtom(environmentA, threadA);
+ expect(registry.get(sessions)).toBeNull();
+
+ const first = summary(threadA, "terminal-1");
+ const other = summary(threadB, "terminal-1");
+ registry.set(metadataAtom, metadataResult(first));
+ const notified: unknown[] = [];
+ const unsubscribe = registry.subscribe(sessions, (value) => notified.push(value));
+ const before = registry.get(sessions);
+
+ registry.set(metadataAtom, metadataResult(first, other));
+ registry.set(metadataAtom, metadataResult(first, { ...other, label: "Renamed" }));
+ expect(notified).toEqual([]);
+ expect(registry.get(sessions)).toBe(before);
+
+ registry.set(metadataAtom, metadataResult({ ...first, label: "Renamed" }, other));
+ expect(notified).toHaveLength(1);
+
+ const previousSuccess = Option.some(metadataResult(first));
+ registry.set(metadataAtom, AsyncResult.failure(Cause.die("unavailable"), { previousSuccess }));
+ expect(registry.get(sessions)).toBeNull();
+ unsubscribe();
+ });
+});
+
describe("attachedTerminalSessionAtom", () => {
it("attaches with the launch input it was keyed by", () => {
attachCalls.length = 0;
diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts
index 00d7926497d23..b1fa8a4b8cd93 100644
--- a/apps/web/src/state/terminalSessions.ts
+++ b/apps/web/src/state/terminalSessions.ts
@@ -1,3 +1,4 @@
+import { useAtomValue } from "@effect/atom-react";
import {
combineTerminalSessionState,
EMPTY_TERMINAL_BUFFER_STATE,
@@ -19,7 +20,7 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { AsyncResult, Atom } from "effect/reactivity";
-import { formatEnvironmentQueryError, useEnvironmentQuery } from "./query";
+import { formatEnvironmentQueryError } from "./query";
import { terminalEnvironment } from "./terminal";
import { readEnvironmentScope, useEnvironmentScope } from "./session";
@@ -194,22 +195,40 @@ export function attachedTerminalSessionAtom(
return attachedTerminalSessionFamily(JSON.stringify([environmentId, terminal]));
}
+const NO_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make | null>(null);
+
+const decodeKnownSessionsKey = Schema.decodeUnknownOption(
+ Schema.fromJsonString(Schema.Tuple([EnvironmentId, Schema.NullOr(ThreadId)])),
+);
+
+// Derived per thread, so a terminal update elsewhere in the environment notifies no one here.
+const knownTerminalSessionsFamily = Atom.family((key: string) => {
+ const [environmentId, threadId] = Option.getOrThrow(decodeKnownSessionsKey(key));
+ return Atom.make((get) => {
+ const metadata = get(terminalEnvironment.metadata({ environmentId, input: null }));
+ return metadata._tag === "Success"
+ ? selectKnownTerminalSessions(metadata.value, environmentId, threadId)
+ : null;
+ }).pipe(Atom.withLabel(`web-known-terminal-sessions:${key}`));
+});
+
+export function knownTerminalSessionsAtom(
+ environmentId: EnvironmentId,
+ threadId: ThreadId | null,
+): Atom.Atom | null> {
+ return knownTerminalSessionsFamily(JSON.stringify([environmentId, threadId]));
+}
+
export function useKnownTerminalSessions(input: {
readonly environmentId: EnvironmentId | null;
readonly threadId: ThreadId | null;
}): ReadonlyArray | null {
const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
- const metadata = useEnvironmentQuery(
+ return useAtomValue(
input.environmentId === null || !canRead
- ? null
- : terminalEnvironment.metadata({
- environmentId: input.environmentId,
- input: null,
- }),
+ ? NO_KNOWN_TERMINAL_SESSIONS_ATOM
+ : knownTerminalSessionsAtom(input.environmentId, input.threadId),
);
- return metadata.data === null || metadata.error !== null
- ? null
- : selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId);
}
export function useThreadRunningTerminalIds(input: {
diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts
index 9d7aae58033f1..6d2ce2a07c90b 100644
--- a/apps/web/src/state/threads.ts
+++ b/apps/web/src/state/threads.ts
@@ -18,6 +18,7 @@ import { AsyncResult, Atom } from "effect/reactivity";
import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
+import { serverEnvironment } from "./server";
import { environmentShell } from "./shell";
export const threadFamilies = createThreadFamilyAtoms(
@@ -39,6 +40,7 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
export const environmentThreadShells = createEnvironmentThreadShellAtoms({
catalogValueAtom: environmentCatalog.catalogValueAtom,
snapshotAtom: threadEnvironment.snapshotAtom,
+ hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom,
});
export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({
childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom,
diff --git a/apps/web/src/textDirection.test.ts b/apps/web/src/textDirection.test.ts
index 3cc618e525f07..0aa7b3c9e1f96 100644
--- a/apps/web/src/textDirection.test.ts
+++ b/apps/web/src/textDirection.test.ts
@@ -13,9 +13,34 @@ describe("textDirection", () => {
"rtl",
],
["English quoting one Arabic word", "translate the label مرحبا for the login screen", "ltr"],
+ [
+ "tied sentences use the whole-text word majority",
+ "Done. تم إصلاح المشكلة في الملف الرئيسي",
+ "rtl",
+ ],
["a tie, by its first word", "React مكتبة", "ltr"],
["a tie that opens in Arabic", "مكتبة React", "rtl"],
["Arabic around numbers and punctuation", "عدّل السطر 42، ثم شغّل الاختبارات (3)", "rtl"],
+ [
+ "mostly Arabic sentences beside one long English one",
+ "هذا مثال على paragraph يحتوي على نص عربي و English words معًا. Arabic text should flow right-to-left while English words like JavaScript, React, and API should remain left-to-right within the same line. الأرقام 1234567890 ورموز مثل @username و #hashtag و 100$ تظهر في الاتجاه الصحيح. هذه جملة لاختبار RTL rendering في المتصفح أو المحرر.",
+ "rtl",
+ ],
+ [
+ "mostly English sentences beside one Arabic one",
+ "Run the migration first. ثم شغّل الاختبارات كلها. Then deploy to staging.",
+ "ltr",
+ ],
+ [
+ "Arabic with inline abbreviations",
+ "هذه جملة عربية تتحدث عن e.g. React i.e. TypeScript JavaScript",
+ "rtl",
+ ],
+ [
+ "an abbreviation at a sentence boundary",
+ "This long English sentence describes the technology sector in the U.S. هذه جملة عربية. وهذه جملة عربية أخرى.",
+ "rtl",
+ ],
])("reads %s", (_, text, direction) => {
expect(textDirection(text)).toBe(direction);
});
diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts
index 412edfbb43309..f48267c16f318 100644
--- a/apps/web/src/threadNotifications.test.ts
+++ b/apps/web/src/threadNotifications.test.ts
@@ -49,6 +49,31 @@ afterEach(() => {
});
describe("playNotificationSound", () => {
+ it("preloads both sounds on unlock and reuses them for playback", async () => {
+ const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked();
+ expect(fetch).toHaveBeenCalledTimes(2);
+ unlockNotificationAudio();
+ await playNotificationSound("completion", () => true);
+ await playNotificationSound("input", () => true);
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(audio.started).toBe(2);
+ });
+
+ it("retries failed preloads on an alert rather than every gesture", async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error("offline"));
+ const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked();
+ await playNotificationSound("completion", () => true);
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ unlockNotificationAudio();
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ vi.mocked(fetch).mockResolvedValue(new Response(new ArrayBuffer(0)));
+ await playNotificationSound("completion", () => true);
+ expect(fetch).toHaveBeenCalledTimes(3);
+ expect(audio.started).toBe(1);
+ });
+
it("plays one sound for threads that complete together", async () => {
const { playNotificationSound } = await loadUnlocked();
diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts
index 3bf3a349f96db..8cb6b29414150 100644
--- a/apps/web/src/threadNotifications.ts
+++ b/apps/web/src/threadNotifications.ts
@@ -85,10 +85,31 @@ const buffers = new Map>();
/** When each sound's current playback ends, on the audio context's clock. */
const playingUntil = new Map();
+function loadNotificationBuffer(context: AudioContext, url: string): Promise {
+ let buffer = buffers.get(url);
+ if (!buffer) {
+ buffer = fetch(url)
+ .then((response) => response.arrayBuffer())
+ .then((data) => context.decodeAudioData(data))
+ .catch((error: unknown) => {
+ buffers.delete(url);
+ throw error;
+ });
+ buffers.set(url, buffer);
+ }
+ return buffer;
+}
+
/** Called from a gesture so browsers allow later background playback. */
export function unlockNotificationAudio() {
+ const firstUnlock = audioContext === undefined;
audioContext ??= new AudioContext();
void audioContext.resume().catch(() => undefined);
+ if (!firstUnlock) return;
+ // Fetch and decode during interaction so the first alert is ready when the thread finishes.
+ for (const url of [completionUrl, inputUrl]) {
+ void loadNotificationBuffer(audioContext, url).catch(() => undefined);
+ }
}
export async function playNotificationSound(
@@ -99,14 +120,7 @@ export async function playNotificationSound(
const context = audioContext;
const url = kind === "completion" ? completionUrl : inputUrl;
try {
- let buffer = buffers.get(url);
- if (!buffer) {
- buffer = fetch(url)
- .then((response) => response.arrayBuffer())
- .then((data) => context.decodeAudioData(data));
- buffers.set(url, buffer);
- }
- const decoded = await buffer;
+ const decoded = await loadNotificationBuffer(context, url);
if (!shouldPlay() || context.state !== "running") return;
// Coalesce same-sound bursts after decode to avoid clipping; input and completion alerts keep separate slots.
if (context.currentTime < (playingUntil.get(url) ?? 0)) return;
@@ -116,6 +130,6 @@ export async function playNotificationSound(
source.start();
playingUntil.set(url, context.currentTime + decoded.duration);
} catch {
- buffers.delete(url);
+ // A failed load is retried on the next alert.
}
}
diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md
index a6a8779910a8b..fd9b4bcc53481 100644
--- a/docs/internals/coder-only.md
+++ b/docs/internals/coder-only.md
@@ -552,7 +552,9 @@ checks that every name here still exists and that every modified upstream file i
- provider-core's `server/t3ToolBridgeInstructions.ts` reuses upstream's orchestration sections,
rewriting "`html_preview`, then `html_render`" to "`html_render`" and dropping the `preview_*`
bullet; its test fails if upstream rewords them.
-- `CodexAdapterV2.ts` configures no `mcp_servers`, attaches T3 context only when the bridge exists,
+- `CodexAdapterV2.ts` configures no `mcp_servers` (neither `t3-code` nor the direct-only
+ `t3-code-direct` server for `t3_thread_wait`, #29026 and #29068; `codexThreadRuntimeParams` adds
+ only upstream's per-thread `T3CODE_THREAD_ID`), attaches T3 context only when the bridge exists,
and takes the bridge text as `t3ToolBridgeSections` (one context entry per section, under
`CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES`); `CodexDeveloperInstructions.ts` and
`ClaudeAdapterV2.ts` describe the bridge command, and Claude pre-approves it as a Bash prefix
@@ -633,7 +635,11 @@ checks that every name here still exists and that every modified upstream file i
- No metrics: `Orchestrator.ts` and `ProjectService.ts` have no `withCommandMetrics`,
`ProviderSessionManager.ts` no session or turn metrics, `ScheduledTaskService.ts` no webhook
metrics, `SecretRequests.ts` no consumed-ref count, `EventSink.ts` no run outcome metrics
- (`RunMetrics.ts`), and `RunExecutionService.ts` no `PreviewAgentActivity`. `EventNdjsonLogger` and
+ (`RunMetrics.ts`), and `RunExecutionService.ts` no `PreviewAgentActivity`. Provider token
+ analytics (#29001) is cut: `telemetry/ProviderUsageAnalytics.ts` keeps only its report types and
+ the no-op `ProviderTurnAnalytics` reference that `ProviderEventIngestor.ts`,
+ `ProviderTurnStartService.ts`, and `RunExecutionService.ts` call, with no analytics layer or
+ request ledger; the token usage counts those turns carry are upstream's. `EventNdjsonLogger` and
`ProviderEventLoggers` keep only the no-op service. provider-core's `maintenanceResolver.ts` and
`providerStatusCache.ts` log the first cause tag in place of the omitted observability helper.
- No `lastErrorCode`: the server's `threadErrorSummary`, `ProjectionStore.ts`'s shell state,
@@ -665,7 +671,8 @@ checks that every name here still exists and that every modified upstream file i
Upstream's `WsRpcGroup` stays dormant (`knip.jsonc` ignores `rpc.ts`'s unused exports).
`CoderWsRpcGroup` omits `server.getDatabaseStorage`, `server.getNpmRelease`,
`server.prepareTriage`, the browser preview's RPCs (it keeps `preview.stopLocalServer` and
- `subscribeDiscoveredLocalServers`), guest share creation, the peer-link RPCs, and
+ `subscribeDiscoveredLocalServers`), guest share creation, the peer-link RPCs,
+ `orchestration.getNativeChildWork` (Antigravity's native child work, #28629), and
the `sourceControl.signIn*`, `sourceControl.appAccess`, and `sourceControl.starred` RPCs.
- `server.ts` (contracts) mirrors `ipc.ts`'s `DesktopUpdateChannelSchema` for
`ServerSelfUpdateInput`, because `ipc.ts` is not carried.
@@ -676,6 +683,9 @@ checks that every name here still exists and that every modified upstream file i
events carry the raw payload.
- Terminal attach input adds `afterSequence` and `afterEpoch`, snapshots add `attachEpoch`, and the
attach stream adds a `resumed` event.
+- `settings.ts` declares `UsageModelFilter` for the `usageModelFilter` server setting (#28952)
+ because contracts' `usage.ts` is not carried; client-runtime's `state/server.ts` has no usage
+ atoms.
- Client settings keep `providerPreferencesByEnvironment`, read only as the legacy lists each
workspace migrates (see [Web state and persistence](#web-state-and-persistence)).
@@ -710,7 +720,7 @@ checks that every name here still exists and that every modified upstream file i
(`server/GitLabMediaFetch.ts`) reads with `glab api` in the minted `cwd` (no `--hostname`),
buffering at most `GITLAB_MEDIA_MAX_BYTES`. The package also exports `server/GitLabCli`, which
`ws.projects.test.ts` stubs.
-- `GitLabCli.ts` fixes bugs still in upstream (checked against upstream `main` 8760d063e9): merge
+- `GitLabCli.ts` fixes bugs still in upstream (checked against upstream `main` 47f2c8d29e): merge
requests between projects post to the source project with a numeric `target_project_id`; merge
request URLs reach `glab` as `--repo … -- `. `glab auth status` lines with a token
label or `glpat-` token are never surfaced; clones accept only GitLab URLs without credentials,
@@ -1052,7 +1062,11 @@ checks that every name here still exists and that every modified upstream file i
Copilot lost-session (#26651), and session-management cases, plus a Coder test that the bridge command reaches the prompt;
`AgentScope.test.ts` drops the launcher check (`agentScopeLauncherScript` is cut);
`GitLabPullRequestCli.legacy.test.ts` expects `DIFF_MAX_OUTPUT_BYTES`; `ProviderReplayFixtures.ts`
- lists only the carried drivers.
+ lists only the carried drivers; `CodexAdapterV2.test.ts` expects `T3CODE_THREAD_ID` without
+ the MCP servers; client-runtime's `assets.test.ts` builds the gateway `ConnectionTarget` in
+ place of upstream's primary target; `ChatView.logic.test.ts` drops preview annotations,
+ `useThreadActions.permissions.test.ts` the mini-player state, `mediaContent.test.ts` the desktop
+ media read, and `settingsSearch.test.ts` the browser search row.
- Upstream's `*.browser.test.*` files run in headless Chromium as the `browser` Vitest project;
`reactCompilerCoverage.test.ts` skips `CODER_UNCOMPILED_FILES`. The fork keeps `jsdom` and
`react-test-renderer` for its unit tests and for `ChatMarkdown.test.tsx`,
@@ -1085,6 +1099,8 @@ them, the file is named here.
navigation (`HistorySwipeNavigation.tsx`, `navigationHistory.ts`), mobile app links
(`MobileAppSettingsRow`), hosted app, or prompt links (`components/promptLinks/`, the "Copy prompt
link" actions, the automation row's link). `Icons.tsx` drops `OnePasswordIcon`.
+ `media/mediaContent.ts`'s `fetchMediaBlob` has no desktop native read after a failed fetch
+ (#29079).
- **Auth, pairing, and relay.** No HTTP/WebSocket listener, session scopes, pairing, DPoP, relay,
or connection catalog; `settings/AddEnvironmentDialog.tsx` is cut. The server's `auth/` keeps
`ServerSecretStore`, its utils, and `serverInternalPaths.ts` (#28041), which the carried file
@@ -1093,7 +1109,9 @@ them, the file is named here.
- **Browser preview and devices.** No in-app browser, preview panel, mini-player, preview MCP
toolkits, device hosts, or agent device shim (port discovery stays: `preview/PortScanner.ts`); provider-core's device instructions never reach an
agent. Without browser preview, `composerDraftStore.ts` keeps no empty draft alive for an open
- page, and `mergeDirectPreviewAnnotation` is not carried.
+ page, and `mergeDirectPreviewAnnotation` is not carried. Deleting a thread clears its
+ right-panel and diff-panel state (`useThreadActions.ts`'s `clearDeletedThreadPanelState`, #28386)
+ but has no preview or mini-player state to clear.
- **Telemetry, usage, and diagnostics.** No T3 telemetry, analytics, client-origin attribution,
metrics, OTLP or trace export (upstream's OTLP `config.test.ts`), diagnostics page, NDJSON event
logs, resource telemetry, `observability/EventLoopMonitor.ts` (no host-freeze toast or
@@ -1115,7 +1133,10 @@ them, the file is named here.
entries), managed Codex (`CodexManagedTokenBroker`, `__managed-chatgpt-token`, ChatGPT Business
admin requests #23361), provider sign-in, and Kilo Code's registry index, installs, auth
controller, logout, native session deletion, and agent provider configuration (`copilotAcp.ts`,
- `devinAcp.ts`, `nativeLogging.ts`, `@noble/hashes`), and the Antigravity send block. Codex
+ `devinAcp.ts`, `nativeLogging.ts`, `@noble/hashes`), the Antigravity send block, and
+ Antigravity's native child work (#28629: `NativeChildWorkQuery.ts`, `NativeChildWorkDialog.tsx`,
+ client-runtime `state/orchestration.ts`'s `nativeChildWork` atom, and the `ChatView.tsx` slot
+ that rendered it). Codex
feedback upload (client-runtime `threadCommands.ts` has no `uploadFeedback`) and SnapShot sources
are cut.
- **Hosted source control other than GitLab.** GitHub, Azure DevOps, Bitbucket, Forgejo, and
@@ -1153,6 +1174,29 @@ them, the file is named here.
reads no reference (an instance whose variable points at one is unavailable with upstream's "Could
not read …" reason) and lists no kind (`secretProviders` writes are rejected); `ws.ts` does not
advertise `secretReferences`.
+- **Managed policy** (#27197). Organization-wide policy files, the Windows registry and macOS
+ profile readers (`ffi-rs`), `t3 policy`, policy-locked settings and providers, runtime-mode
+ ceilings, and their notices are cut: no contracts `managedPolicy.ts`, shared
+ `managedPolicyFile.ts`, server `policy/`, web `ManagedPolicy.tsx` or `state/managedPolicy.ts`, or
+ `docs/user/managed-policy.md`. The sync merged #27197 with the tree of its parent, so these carried
+ files keep upstream's code from before it in place of its hunks: contracts `auth.ts`, `index.ts`,
+ `orchestratorMcp.ts`, `providerPolicy.ts` (no `isRuntimeModeAbove`), `scheduledTask.ts`
+ (`blockedByPolicy`), `server.ts`, and `settings.ts`; provider-core `ProviderAdapter.ts`
+ (`maxRuntimeMode`) and `instanceEnvironment.ts`; provider-acp `adapter.ts`
+ (`runtimeModeForSessionMode`); server `auth/utils.ts`, `mcp/McpInvocationContext.ts`,
+ `mcp/OrchestratorMcpService.ts`, `mcp/threadAccess.ts`, `mcp/toolkits/project/handlers.ts`,
+ `ClaudeAdapterV2.ts`, `CheckpointRollbackService.ts`, `DispatchModeLimit.ts`, `Orchestrator.ts`,
+ `ProviderTurnStartService.ts`, `RestartContinuation.ts`, `RuntimePolicy.ts`,
+ `ThreadMessageIntake.ts`, `testkit/ProviderReplayHarness.ts`, `AgentSessionImporter.ts`,
+ `ProviderInstanceRegistry.ts`, `ProviderInstanceRegistryHydration.ts`, `ScheduledTaskService.ts`,
+ `server.ts`, `serverSettings.ts`, and `ws.ts`; web `ChatComposer.tsx`,
+ `ComposerFooterControls.tsx`, `ModelPickerSidebar.tsx`, `ThreadAutomationsPanel.tsx`,
+ `AddProviderInstanceDialog.tsx`, `LabsSettingsPanel.tsx`, `ProjectDefaultsSettings.tsx`,
+ `ProviderInstanceCard.tsx`, `ProviderSettingsForm.tsx`, `ProviderSettingsPanel.tsx`,
+ `ScheduledTasksSettings.tsx`, `SettingsPanels.tsx`, `StorageSettings.tsx`, and
+ `settingsLayout.tsx`; and `docs/user/install.md` and `permission-modes.md`. Later upstream code
+ that builds on #27197's non-policy refactors takes upstream's form (`CodexDriver.ts` passes
+ `HostProcess.Environment` to `mergeProviderInstanceEnvironment`).
- **Add to another machine** (#22180, #22254). It copies ignored files such as `.env` between
workspaces through the browser: no `listIgnoredFiles`, `projects.listIgnoredFiles`
(`ignoredFileListing` omitted), client-runtime `projectCommands.ts` command, `BranchToolbar.tsx`
diff --git a/docs/internals/sync.md b/docs/internals/sync.md
index 14f25556c6dcd..d8e01e10d536a 100644
--- a/docs/internals/sync.md
+++ b/docs/internals/sync.md
@@ -89,6 +89,17 @@ only fork behavior to keep. Commands run from the repository root of the sync wo
comment blocks whole: removing upstream's lines one at a time can drop a `/**` or `*/` the
fork's text still needs (typecheck catches the orphaned lines). Afterwards, list comment lines
upstream deleted that the result still has, since an auto-merged hunk can keep them too.
+- To cut one upstream commit whole (47f2c8d29e: managed policy, #27197), make it the last commit
+ of its own stage. Merge its parent on a temporary branch and commit; then, on the sync branch,
+ `git merge --no-ff --no-commit ` and `git read-tree --reset -u `, so the
+ merge records the commit as merged with none of its hunks. Later commits that edit its lines then
+ conflict in the next stage, and `lost` lists its lines; name every carried file it touched in the
+ seams doc. Where a later commit builds on one of its non-feature refactors (#24664's
+ `CodexDriver.ts` passing `HostProcess.Environment`), take upstream's form.
+- A keep candidate whose importers only need a few declarations (#29001's
+ `telemetry/ProviderUsageAnalytics.ts`, whose analytics layer is cut) can stay as a reduced upstream
+ file under the deleted directory: keep the declarations the carried files import, mark it
+ `Coder:`, and narrow the `DELETED_PATHS` entry with a RegExp.
- A mechanical upstream refactor (e.g. e85539ab59's traced `Effect.fn` wrappers, #21989–#22000)
re-indents whole functions, so conflicts span the fork's rewrites. Merge it in its own stage. For a
file the refactor alone touched, take upstream's file and replay the fork's delta
diff --git a/docs/orchestration-v2/feature-lifecycles.md b/docs/orchestration-v2/feature-lifecycles.md
index 4e1e1a0568ae5..76818736bc758 100644
--- a/docs/orchestration-v2/feature-lifecycles.md
+++ b/docs/orchestration-v2/feature-lifecycles.md
@@ -410,7 +410,7 @@ rollback.toRun(threadId, targetRunOrdinal)
-> delete or mark stale later checkpoint refs
```
-Codex `thread/rollback` returns an authoritative provider thread snapshot. Other providers may require a synthetic provider thread restart from context.
+Codex `thread/revert` returns updated loaded thread metadata with an empty `turns` array; retained history must be hydrated through `thread/turns/list`. Other providers may require a synthetic provider thread restart from context.
Nested rollback:
diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md
index f46803f9d67b6..11e7bd9a8d06e 100644
--- a/docs/orchestration-v2/orchestrator-mcp-server.md
+++ b/docs/orchestration-v2/orchestrator-mcp-server.md
@@ -58,17 +58,18 @@ check the `orchestration` capability before reading or mutating state.
### Codex V2
-Codex app-server receives the remote MCP server through command-line config
-overrides:
-
-```text
--c mcp_servers.t3-code.url=http://127.0.0.1:/mcp
--c mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"
-```
-
-The provider-session token is placed in `T3_MCP_BEARER_TOKEN`. Both the
-production Codex launcher and the injectable test launcher use the same
-projection helper.
+Codex gets T3's MCP server in each thread's `thread/start`, `thread/resume`,
+and `thread/fork` config, with the provider-session token in an
+`Authorization` header. The same server is registered twice:
+
+- `t3-code`: every tool except `t3_thread_wait`.
+- `t3-code-direct`: only `t3_thread_wait`, with `omit_tools_from = ["code_mode", "deferred"]`.
+
+Code Mode runs a nested call as part of a script, so the model keeps working
+while a blocking wait runs. The second entry makes the wait a direct tool call,
+like Codex's own wait tools. Codex reports calls as `.`, so
+`packages/shared/src/t3McpToolPresentation.ts` must resolve both server names.
+Tool display and pending-work detection depend on it.
### Claude Agent SDK V2
diff --git a/docs/orchestration-v2/thread-lineage-and-context-transfer.md b/docs/orchestration-v2/thread-lineage-and-context-transfer.md
index 2bdb17e4234df..7d76582b4cb61 100644
--- a/docs/orchestration-v2/thread-lineage-and-context-transfer.md
+++ b/docs/orchestration-v2/thread-lineage-and-context-transfer.md
@@ -227,9 +227,9 @@ This hook checks pending context transfers targeting the thread/run and chooses
Provider adapters own native details. The orchestrator owns the relationship, source point, durable transfer record, and command receipts.
-For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. This is the only viable path on paginated Codex threads, which reject `thread/rollback`. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns — and reports an explicit failure when the forked thread uses paginated history, since that fallback cannot be honored there. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy.
+For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns, using `thread/revert` on paginated history and reporting an explicit failure on legacy history, which Codex 0.156 cannot revert. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy.
-The same paginated-history constraint applies to direct checkpoint rollback: `thread/rollback` only works on legacy-history Codex threads. The V2 adapter probes `historyMode` before rolling back and fails explicitly on paginated threads rather than sending a request Codex will reject. The paginated equivalent (page `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`) is not implemented yet.
+Direct checkpoint rollback also requires paginated Codex history. The V2 adapter checks `historyMode` before rolling back and fails explicitly on legacy history, which Codex 0.156 cannot revert. On paginated history, it pages `thread/turns/list` to find the first removed turn, then sends `thread/revert` with `beforeTurnId`.
## Data Ownership
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index 24aeebac154e3..dadda2d04f641 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -239,6 +239,8 @@ the helper is running. Offline workspaces retain their settings and resume clean
Worktree rules can remove T3-managed checkouts after inactivity, after a thread settles, after
their branch is merged, when unchanged from the remote default branch, or after deleting their last
thread. Set settled retention to 0 days to remove a worktree at the next cleanup after settlement.
+In repositories without remotes, unchanged-worktree cleanup compares against the branch checked out
+in the project workspace; if that checkout has a detached HEAD, the worktree is kept.
Active sessions, terminals, and shared checkouts prevent removal. **Keep worktrees with local
changes** defaults to **Uncommitted changes**, which protects tracked edits and untracked files but
deletes ignored files such as `.env` and build output. **Any local files** also protects ignored
diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts
index 508a2f5694d02..d2f5fb4e78903 100644
--- a/packages/client-runtime/src/handoff.test.ts
+++ b/packages/client-runtime/src/handoff.test.ts
@@ -8,6 +8,7 @@ import {
import { describe, expect, it } from "vite-plus/test";
import {
resolveHandoffEndpoints,
+ resolveModelSwitchChange,
resolvePendingHandoff,
type HandoffTimelineRun,
} from "./handoff.ts";
@@ -173,3 +174,99 @@ describe("pending context handoff", () => {
).toBeNull();
});
});
+
+describe("model switch change", () => {
+ const selection = (model: string, options?: Record) => ({
+ instanceId: from,
+ model,
+ ...(options === undefined
+ ? {}
+ : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }),
+ });
+ const providers = [
+ {
+ instanceId: from,
+ models: ["gpt", "mini"].map((slug) => ({
+ slug,
+ capabilities: {
+ optionDescriptors: [
+ { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] },
+ {
+ id: "thought_level",
+ type: "select",
+ role: "reasoning",
+ options: [
+ { id: "medium", label: "Medium", isDefault: true },
+ { id: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ })),
+ },
+ ] as unknown as ServerProvider[];
+
+ it("shows only what changed, reading an unset effort as the default", () => {
+ expect(
+ resolveModelSwitchChange(
+ { from: selection("gpt"), to: selection("gpt", { thought_level: "high" }) },
+ providers,
+ ),
+ ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } });
+ expect(
+ resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers),
+ ).toEqual({ modelChanged: true, effort: null });
+ });
+
+ it("ignores changes the user cannot see", () => {
+ expect(
+ resolveModelSwitchChange(
+ { from: selection("gpt"), to: selection("gpt", { thought_level: "medium" }) },
+ providers,
+ ),
+ ).toBeNull();
+ expect(
+ resolveModelSwitchChange(
+ { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) },
+ providers,
+ ),
+ ).toBeNull();
+ // A cataloged model without an effort control ignores stale effort options.
+ expect(
+ resolveModelSwitchChange(
+ {
+ from: selection("plain", { reasoningEffort: "low" }),
+ to: selection("plain", { reasoningEffort: "high" }),
+ },
+ [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[],
+ ),
+ ).toBeNull();
+ });
+
+ it("falls back to stated efforts without a catalog", () => {
+ expect(
+ resolveModelSwitchChange(
+ {
+ from: selection("gpt", { reasoningEffort: "low" }),
+ to: selection("gpt", { reasoningEffort: "high" }),
+ },
+ [],
+ ),
+ ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } });
+ expect(
+ resolveModelSwitchChange(
+ {
+ from: selection("pi", { thinking: "low" }),
+ to: selection("pi", { thinking: "high" }),
+ },
+ [],
+ ),
+ ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } });
+ expect(
+ resolveModelSwitchChange(
+ { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) },
+ [],
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts
index a26c018ad9cdf..c4e7b09595284 100644
--- a/packages/client-runtime/src/handoff.ts
+++ b/packages/client-runtime/src/handoff.ts
@@ -1,12 +1,20 @@
import type {
ModelSelection,
+ OrchestrationV2ModelSwitch,
OrchestrationV2Run,
OrchestrationV2TurnItem,
ProviderInstanceId,
ServerProvider,
+ ServerProviderModel,
} from "@t3tools/contracts";
+import {
+ getModelSelectionStringOptionValue,
+ getProviderOptionCurrentValue,
+} from "@t3tools/shared/model";
+import { isReasoningSelect } from "./modelTraits.ts";
import type { ThreadStructure } from "./state/models.ts";
+import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts";
export type HandoffTimelineRun = Pick<
OrchestrationV2Run,
@@ -80,3 +88,51 @@ function latestRunModelBefore(
}
return latest?.modelSelection.model;
}
+
+export interface ModelSwitchChange {
+ readonly modelChanged: boolean;
+ /** Effort labels, set only when the effort changed. */
+ readonly effort: { readonly from: string; readonly to: string } | null;
+}
+
+/**
+ * What a recorded model switch visibly changed, or null when it changed neither the model nor the
+ * effort. The composer omits an untouched effort, so an unset effort reads as the model's default.
+ */
+export function resolveModelSwitchChange(
+ { from, to }: OrchestrationV2ModelSwitch,
+ providers: ReadonlyArray,
+): ModelSwitchChange | null {
+ const modelsOf = (selection: ModelSelection) =>
+ providers.find((provider) => provider.instanceId === selection.instanceId)?.models;
+ const fromEffort = resolveEffort(from, modelsOf(from));
+ const toEffort = resolveEffort(to, modelsOf(to));
+ const modelChanged = from.model !== to.model;
+ const effort =
+ fromEffort && toEffort && fromEffort.value !== toEffort.value
+ ? { from: fromEffort.label, to: toEffort.label }
+ : null;
+ return modelChanged || effort ? { modelChanged, effort } : null;
+}
+
+/** The composer's reasoning control for the model; stated values only when the catalog lacks it. */
+function resolveEffort(
+ selection: ModelSelection,
+ models: ReadonlyArray | undefined,
+): { readonly value: string; readonly label: string } | undefined {
+ const model = models?.find((candidate) => candidate.slug === selection.model);
+ if (model === undefined) {
+ // Pi states its effort as `thinking`; the catalog would otherwise mark it a reasoning control.
+ const value = [...REASONING_EFFORT_OPTION_IDS, "thinking"]
+ .map((id) => getModelSelectionStringOptionValue(selection, id))
+ .find((candidate) => candidate !== undefined);
+ return value === undefined ? undefined : { value, label: value };
+ }
+ const descriptor = model.capabilities?.optionDescriptors?.find(isReasoningSelect);
+ if (descriptor === undefined) return undefined;
+ const value =
+ getModelSelectionStringOptionValue(selection, descriptor.id) ??
+ getProviderOptionCurrentValue(descriptor, selection);
+ if (typeof value !== "string") return undefined;
+ return { value, label: descriptor.options.find((option) => option.id === value)?.label ?? value };
+}
diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts
index f1d8ce45cd8bb..406443a21dbb5 100644
--- a/packages/client-runtime/src/operations/projects.test.ts
+++ b/packages/client-runtime/src/operations/projects.test.ts
@@ -9,11 +9,42 @@ import {
getDefaultCloneUrl,
getNewProjectPublishTarget,
getNewProjectPublishTargets,
+ githubIncompleteOwnerRepositoryMessage,
normalizePastedCloneUrl,
} from "./projects.ts";
import type { EnvironmentProject } from "../state/models.ts";
describe("add project shared logic", () => {
+ it("asks for a repository name when GitHub input is only an owner path", () => {
+ const github = SourceControlProviderKind.make("github");
+ expect(githubIncompleteOwnerRepositoryMessage(github, "pingdotgg/")).toBe(
+ "Enter the full owner/repository name.",
+ );
+ expect(githubIncompleteOwnerRepositoryMessage(github, " pingdotgg// ")).toBe(
+ "Enter the full owner/repository name.",
+ );
+ expect(githubIncompleteOwnerRepositoryMessage(github, "mona-cat_octo/")).toBe(
+ "Enter the full owner/repository name.",
+ );
+
+ for (const input of [
+ "pingdotgg/t3code",
+ "mona-cat_octo/repo",
+ "pingdotgg",
+ "https://github.com/pingdotgg/t3code.git",
+ "git@github.com:pingdotgg/t3code.git",
+ ]) {
+ expect(githubIncompleteOwnerRepositoryMessage(github, input)).toBeNull();
+ }
+ expect(
+ githubIncompleteOwnerRepositoryMessage(SourceControlProviderKind.make("gitlab"), "group/"),
+ ).toBeNull();
+ expect(githubIncompleteOwnerRepositoryMessage("url", "owner/")).toBeNull();
+ expect(
+ githubIncompleteOwnerRepositoryMessage(SourceControlProviderKind.make("forgejo"), "owner/"),
+ ).toBeNull();
+ });
+
// Coder: shorthand goes to the first host, GitLab, over its default SSH transport.
it("routes owner/repository shorthand to the default host", () => {
expect(normalizePastedCloneUrl("imputnet/helium")).toBe("git@gitlab.com:imputnet/helium.git");
diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts
index 98c7e00a71631..cbdb885bdaa10 100644
--- a/packages/client-runtime/src/operations/projects.ts
+++ b/packages/client-runtime/src/operations/projects.ts
@@ -106,6 +106,25 @@ export function defaultHostCloneUrl(repository: string): string | null {
: `git@${definition.publicHost}:${path}`;
}
+/** Owner present, repository name missing: `owner/` or `owner//`. */
+const INCOMPLETE_GITHUB_OWNER_PATH = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,38})\/+$/;
+
+const INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE = "Enter the full owner/repository name.";
+
+/**
+ * Message to show when a GitHub add-project field is only `owner/`.
+ * Lookup would send that to GitHub and come back as a generic failure.
+ * Returns null for complete names, bare repo names, URLs, and other sources.
+ */
+export function githubIncompleteOwnerRepositoryMessage(
+ source: AddProjectRemoteSource,
+ input: string,
+): string | null {
+ if (source !== "github") return null;
+ if (!INCOMPLETE_GITHUB_OWNER_PATH.test(input.trim())) return null;
+ return INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE;
+}
+
export function normalizePastedCloneUrl(input: string): string {
const trimmed = input.trim();
return REPOSITORY_SHORTHAND.test(trimmed) ? (defaultHostCloneUrl(trimmed) ?? trimmed) : trimmed;
diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts
index f69a814df7d08..acb7b8c5728e4 100644
--- a/packages/client-runtime/src/state/archivedThreads.test.ts
+++ b/packages/client-runtime/src/state/archivedThreads.test.ts
@@ -1,12 +1,18 @@
-import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ type OrchestrationV2ArchivedShellSnapshot,
+ type ScopedThreadRef,
+ ThreadId,
+} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
-import { expect, it } from "vite-plus/test";
+import { describe, expect, it } from "vite-plus/test";
import {
createArchivedThreadSnapshotsAtomFamily,
makeArchivedThreadsEnvironmentKey,
} from "./archivedThreads.ts";
+import { v2Project, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
it("does not expose an archived snapshot failure message", () => {
const environmentId = EnvironmentId.make("env-sensitive");
@@ -17,6 +23,10 @@ it("does not expose an archived snapshot failure message", () => {
Cause.fail(new Error("credential=secret-value")),
),
),
+ hiddenThreadTagsAtom: () => Atom.make(null),
+ hiddenThreadKeysAtom: Atom.make((): ReadonlySet => new Set()),
+ liveThreadRefsAtom: Atom.make((): ReadonlyArray => []),
+ anyEnvironmentHidingAtom: Atom.make(false),
labelPrefix: "test:archived-thread-snapshots",
});
const registry = AtomRegistry.make();
@@ -29,3 +39,70 @@ it("does not expose an archived snapshot failure message", () => {
registry.dispose();
});
+
+describe("archived delegated tasks follow their parent on another machine", () => {
+ const parentEnvironmentId = EnvironmentId.make("env-parent");
+ const taskEnvironmentId = EnvironmentId.make("env-task");
+ const parentId = ThreadId.make("thread-parent");
+ const page = (
+ threads: OrchestrationV2ArchivedShellSnapshot["threads"],
+ ): OrchestrationV2ArchivedShellSnapshot => ({
+ schemaVersion: 1,
+ snapshotSequence: 1,
+ projects: [v2Project],
+ threads,
+ });
+ const taskPage = page([
+ {
+ ...v2ThreadShell,
+ id: ThreadId.make("thread-task"),
+ delegatedFrom: { environmentId: parentEnvironmentId, threadId: parentId, title: "p" },
+ },
+ ]);
+ const archivedTaggedParent = page([{ ...v2ThreadShell, id: parentId, tags: ["private"] }]);
+
+ it.each([
+ { name: "archived tagged parent", parentPage: archivedTaggedParent, live: false, tasks: 0 },
+ { name: "live visible parent", parentPage: page([]), live: true, tasks: 1 },
+ { name: "parent page not loaded yet", parentPage: null, live: false, tasks: 0 },
+ { name: "archive scoped to the task's machine", parentPage: undefined, live: false, tasks: 0 },
+ {
+ name: "only a third machine hides tags",
+ parentPage: page([]),
+ live: false,
+ tasks: 0,
+ parentTags: null,
+ },
+ ])("$name", ({ parentPage, live, tasks, parentTags = ["private"] }) => {
+ const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
+ getSnapshotAtom: (environmentId) =>
+ Atom.make(
+ environmentId === taskEnvironmentId
+ ? AsyncResult.success(taskPage)
+ : parentPage === null || parentPage === undefined
+ ? AsyncResult.initial(true)
+ : AsyncResult.success(parentPage),
+ ),
+ hiddenThreadTagsAtom: (environmentId) =>
+ Atom.make(environmentId === parentEnvironmentId ? parentTags : null),
+ hiddenThreadKeysAtom: Atom.make((): ReadonlySet => new Set()),
+ liveThreadRefsAtom: Atom.make((): ReadonlyArray =>
+ live ? [{ environmentId: parentEnvironmentId, threadId: parentId }] : [],
+ ),
+ // The parent's machine hides tags; a scoped archive learns that only from this flag.
+ anyEnvironmentHidingAtom: Atom.make(true),
+ labelPrefix: "test:archived-thread-snapshots",
+ });
+ const registry = AtomRegistry.make();
+ const { snapshots } = registry.get(
+ snapshotsAtom(
+ makeArchivedThreadsEnvironmentKey(
+ parentPage === undefined ? [taskEnvironmentId] : [parentEnvironmentId, taskEnvironmentId],
+ ),
+ ),
+ );
+ const taskSnapshot = snapshots.find((entry) => entry.environmentId === taskEnvironmentId);
+ expect(taskSnapshot?.snapshot.threads.length).toBe(tasks);
+ registry.dispose();
+ });
+});
diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts
index 3a67dc1c52fcf..067a913d018b4 100644
--- a/packages/client-runtime/src/state/archivedThreads.ts
+++ b/packages/client-runtime/src/state/archivedThreads.ts
@@ -1,9 +1,16 @@
-import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ type OrchestrationV2ArchivedShellSnapshot,
+ type ScopedThreadRef,
+} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
import * as Order from "effect/Order";
import { AsyncResult, Atom } from "effect/reactivity";
+import { threadKey } from "./entities.ts";
+import { hiddenThreadKeys, type TagFilteredEnvironment } from "./threadTagFilter.ts";
+
export interface ArchivedSnapshotEntry {
readonly environmentId: EnvironmentId;
readonly snapshot: OrchestrationV2ArchivedShellSnapshot;
@@ -40,11 +47,21 @@ export function createArchivedThreadSnapshotsAtomFamily(options: {
readonly getSnapshotAtom: (
environmentId: EnvironmentId,
) => Atom.Atom>;
+ readonly hiddenThreadTagsAtom: (
+ environmentId: EnvironmentId,
+ ) => Atom.Atom | null>;
+ /** Live threads the tag filter hides, so archived children of hidden parents stay hidden. */
+ readonly hiddenThreadKeysAtom: Atom.Atom>;
+ /** Live threads, so archived children of loaded, visible parents stay visible. */
+ readonly liveThreadRefsAtom: Atom.Atom>;
+ /** Whether any connected environment hides tags, including ones outside this archive's scope. */
+ readonly anyEnvironmentHidingAtom: Atom.Atom;
readonly labelPrefix: string;
}) {
return Atom.family((environmentKey: string) =>
Atom.make((get): ArchivedThreadSnapshotsState => {
const snapshots: ArchivedSnapshotEntry[] = [];
+ const environments: TagFilteredEnvironment[] = [];
let error: string | null = null;
let isLoading = false;
@@ -56,13 +73,41 @@ export function createArchivedThreadSnapshotsAtomFamily(options: {
if (snapshot !== null) {
snapshots.push({ environmentId, snapshot });
}
+ // A page that has not arrived still counts, so its tags apply to children elsewhere.
+ environments.push({
+ environmentId,
+ threads: snapshot?.threads ?? [],
+ hiddenTags: get(options.hiddenThreadTagsAtom(environmentId)),
+ });
if (error === null && result._tag === "Failure") {
error = "Failed to load archived threads.";
}
}
- return { snapshots, error, isLoading };
+ // One pass over every environment's page, so archived tasks follow parents archived elsewhere.
+ // Any connected machine may hide an unloaded parent, so the global flag decides, not the scope.
+ const hidingAnywhere = get(options.anyEnvironmentHidingAtom);
+ const hidden = hiddenThreadKeys(
+ environments,
+ get(options.hiddenThreadKeysAtom),
+ hidingAnywhere ? new Set(get(options.liveThreadRefsAtom).map(threadKey)) : undefined,
+ hidingAnywhere,
+ );
+ if (hidden.size === 0) return { snapshots, error, isLoading };
+ return {
+ snapshots: snapshots.map(({ environmentId, snapshot }) => ({
+ environmentId,
+ snapshot: {
+ ...snapshot,
+ threads: snapshot.threads.filter(
+ (thread) => !hidden.has(threadKey({ environmentId, threadId: thread.id })),
+ ),
+ },
+ })),
+ error,
+ isLoading,
+ };
}).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)),
);
}
diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts
index e156688ea584f..ae4157805f0dc 100644
--- a/packages/client-runtime/src/state/assets.test.ts
+++ b/packages/client-runtime/src/state/assets.test.ts
@@ -1,10 +1,28 @@
import { describe, expect, it } from "@effect/vitest";
-import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
+import { type AssetCreateUrlResult, EnvironmentId, WS_METHODS } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
-import { createProjectFaviconUrlAtomFamily } from "./assets.ts";
+import {
+ AVAILABLE_CONNECTION_STATE,
+ ConnectionTarget,
+ type PreparedConnection,
+ type SupervisorConnectionState,
+} from "../connection/model.ts";
+import * as EnvironmentRegistry from "../connection/registry.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import type * as RpcSession from "../rpc/session.ts";
+import {
+ ASSET_URL_MAX_IN_FLIGHT,
+ createAssetEnvironmentAtoms,
+ createProjectFaviconUrlAtomFamily,
+} from "./assets.ts";
describe("project favicon URL cache", () => {
it("does not reuse another environment, workspace, or selected icon's cached URL", () => {
@@ -38,3 +56,177 @@ describe("project favicon URL cache", () => {
}
});
});
+
+/**
+ * Environments whose `assets.createUrl` never answers until the test releases a call.
+ * `started` receives the environment of each call as it begins, and `inFlight` counts
+ * calls that have begun and not yet been released or interrupted.
+ */
+const makeAssetUrlHarness = Effect.fn("TestAssetUrl.makeHarness")(function* (
+ environmentIds: ReadonlyArray,
+) {
+ const started = yield* Queue.unbounded();
+ const inFlight = new Map();
+ const maxInFlight = new Map();
+ const releases = new Map>();
+ const supervisors = new Map<
+ EnvironmentId,
+ EnvironmentSupervisor.EnvironmentSupervisor["Service"]
+ >();
+ const sessions = new Map<
+ EnvironmentId,
+ SubscriptionRef.SubscriptionRef>
+ >();
+ const makeSession = (environmentId: EnvironmentId) =>
+ ({
+ client: {
+ [WS_METHODS.assetsCreateUrl]: () =>
+ Effect.gen(function* () {
+ const now = (inFlight.get(environmentId) ?? 0) + 1;
+ inFlight.set(environmentId, now);
+ maxInFlight.set(environmentId, Math.max(maxInFlight.get(environmentId) ?? 0, now));
+ yield* Queue.offer(started, environmentId);
+ yield* Queue.take(releases.get(environmentId)!);
+ return { relativeUrl: "/api/assets/token/image.png", expiresAt: 4_000_000_000_000 };
+ }).pipe(
+ Effect.ensuring(
+ Effect.sync(() =>
+ inFlight.set(environmentId, (inFlight.get(environmentId) ?? 1) - 1),
+ ),
+ ),
+ ),
+ },
+ }) as unknown as RpcSession.RpcSession;
+ for (const environmentId of environmentIds) {
+ releases.set(environmentId, yield* Queue.unbounded());
+ const session = yield* SubscriptionRef.make(Option.some(makeSession(environmentId)));
+ sessions.set(environmentId, session);
+ supervisors.set(
+ environmentId,
+ EnvironmentSupervisor.EnvironmentSupervisor.of({
+ // Coder: the gateway connection target stands in for upstream's primary target.
+ target: new ConnectionTarget({
+ environmentId,
+ label: environmentId,
+ httpBaseUrl: "https://assets.example.test",
+ wsBaseUrl: "wss://assets.example.test",
+ }),
+ state: yield* SubscriptionRef.make({
+ ...AVAILABLE_CONNECTION_STATE,
+ desired: true,
+ network: "online",
+ phase: "connected",
+ attempt: 1,
+ generation: 1,
+ }),
+ session,
+ prepared: yield* SubscriptionRef.make>(Option.none()),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]),
+ );
+ }
+ const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (environmentId, effect) =>
+ Effect.provideService(
+ effect,
+ EnvironmentSupervisor.EnvironmentSupervisor,
+ supervisors.get(environmentId)!,
+ );
+ const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = (
+ environmentId,
+ stream,
+ ) =>
+ Stream.provideService(
+ stream,
+ EnvironmentSupervisor.EnvironmentSupervisor,
+ supervisors.get(environmentId)!,
+ );
+ const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
+ run,
+ followStream,
+ } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]);
+ const registry = AtomRegistry.make();
+ yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+ const atoms = createAssetEnvironmentAtoms(
+ Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry)),
+ );
+ return {
+ started,
+ inFlight: (environmentId: EnvironmentId) => inFlight.get(environmentId) ?? 0,
+ maxInFlight: (environmentId: EnvironmentId) => maxInFlight.get(environmentId) ?? 0,
+ release: (environmentId: EnvironmentId) => Queue.offer(releases.get(environmentId)!, undefined),
+ replaceSession: (environmentId: EnvironmentId) =>
+ SubscriptionRef.set(sessions.get(environmentId)!, Option.some(makeSession(environmentId))),
+ mountImages: (environmentId: EnvironmentId, count: number) => {
+ for (let index = 0; index < count; index += 1) {
+ registry.mount(
+ atoms.createUrl({
+ environmentId,
+ input: { resource: { _tag: "attachment", attachmentId: `image-${index}` } },
+ }),
+ );
+ }
+ },
+ };
+});
+
+describe("asset URL requests", () => {
+ const remote = EnvironmentId.make("asset-remote");
+ const other = EnvironmentId.make("asset-other");
+
+ it.effect("keeps a thread of images from flooding one environment with requests", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeAssetUrlHarness([remote]);
+ const total = ASSET_URL_MAX_IN_FLIGHT * 3;
+ harness.mountImages(remote, total);
+
+ for (let call = 0; call < ASSET_URL_MAX_IN_FLIGHT; call += 1) {
+ yield* Queue.take(harness.started);
+ }
+ expect(harness.inFlight(remote)).toBe(ASSET_URL_MAX_IN_FLIGHT);
+
+ // Each answer admits the next waiting request.
+ for (let call = 0; call < total; call += 1) {
+ yield* harness.release(remote);
+ if (call < total - ASSET_URL_MAX_IN_FLIGHT) yield* Queue.take(harness.started);
+ }
+ expect(harness.maxInFlight(remote)).toBe(ASSET_URL_MAX_IN_FLIGHT);
+ }),
+ ),
+ );
+
+ it.effect("limits each environment on its own", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeAssetUrlHarness([remote, other]);
+ harness.mountImages(remote, ASSET_URL_MAX_IN_FLIGHT + 2);
+ for (let call = 0; call < ASSET_URL_MAX_IN_FLIGHT; call += 1) {
+ yield* Queue.take(harness.started);
+ }
+ harness.mountImages(other, 1);
+ expect(yield* Queue.take(harness.started)).toBe(other);
+ expect(harness.inFlight(remote)).toBe(ASSET_URL_MAX_IN_FLIGHT);
+ }),
+ ),
+ );
+
+ it.effect("frees the limit when the session behind the requests is replaced", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeAssetUrlHarness([remote]);
+ harness.mountImages(remote, ASSET_URL_MAX_IN_FLIGHT * 2);
+ for (let call = 0; call < ASSET_URL_MAX_IN_FLIGHT; call += 1) {
+ yield* Queue.take(harness.started);
+ }
+
+ yield* harness.replaceSession(remote);
+ for (let call = 0; call < ASSET_URL_MAX_IN_FLIGHT; call += 1) {
+ yield* Queue.take(harness.started);
+ }
+ expect(harness.maxInFlight(remote)).toBe(ASSET_URL_MAX_IN_FLIGHT);
+ }),
+ ),
+ );
+});
diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts
index 3b50dd1590ca7..2845ca66cf272 100644
--- a/packages/client-runtime/src/state/assets.ts
+++ b/packages/client-runtime/src/state/assets.ts
@@ -18,6 +18,7 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import * as EnvironmentRegistry from "../connection/registry.ts";
@@ -29,6 +30,7 @@ import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;
+export const ASSET_URL_MAX_IN_FLIGHT = 6;
export class InvalidAssetCollectionKeyError extends Schema.TaggedError()(
"InvalidAssetCollectionKeyError",
@@ -138,15 +140,27 @@ export function createAssetEnvironmentAtoms(
readonly httpBaseUrl: string;
} | null>,
) {
+ // A thread with hundreds of images asks for hundreds of URLs at once. Over a slow remote
+ // they would all wait on one socket and read as stalled, so each environment gets a few
+ // at a time. A query that is interrupted, such as by a new session, gives its slot back.
+ const mintLimits = new Map();
+ const mint = (environmentId: EnvironmentId, input: AssetCreateUrlInput) => {
+ let limit = mintLimits.get(environmentId);
+ if (limit === undefined) {
+ limit = Semaphore.makeUnsafe(ASSET_URL_MAX_IN_FLIGHT);
+ mintLimits.set(environmentId, limit);
+ }
+ return request(WS_METHODS.assetsCreateUrl, input).pipe(limit.withPermit);
+ };
const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
- const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
+ const result = yield* mint(supervisor.target.environmentId, input).pipe(Effect.result);
if (Result.isSuccess(result)) return result.success;
const error = result.failure;
const resource = input.resource;
const local = localMediaEnvironment
? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)
: null;
- const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
if (
!local ||
local.environmentId === supervisor.target.environmentId ||
@@ -163,7 +177,7 @@ export function createAssetEnvironmentAtoms(
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
// Map the full cause so retry defects and interruption survive.
const asset = yield* registry
- .run(local.environmentId, request(WS_METHODS.assetsCreateUrl, input))
+ .run(local.environmentId, mint(local.environmentId, input))
.pipe(Effect.catchCause((cause) => Effect.failCause(Cause.map(cause, () => error))));
return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href };
});
diff --git a/packages/client-runtime/src/state/olderSettledThreads.ts b/packages/client-runtime/src/state/olderSettledThreads.ts
index c4d7e2f665264..47779f110efdc 100644
--- a/packages/client-runtime/src/state/olderSettledThreads.ts
+++ b/packages/client-runtime/src/state/olderSettledThreads.ts
@@ -63,6 +63,21 @@ export interface OlderSettledShelfEnvironment {
readonly pages: OlderSettledPages;
}
+/**
+ * Scopes in one order, so a shelf keyed on them keeps its pages when the caller's project order
+ * changes. Loading a page can reorder the sidebar's projects, which would otherwise drop the page.
+ */
+export function sortOlderSettledScopes(
+ scopes: ReadonlyArray,
+): ReadonlyArray {
+ return scopes
+ .map((scope) => ({
+ environmentId: scope.environmentId,
+ projectIds: scope.projectIds === null ? null : [...scope.projectIds].sort(),
+ }))
+ .sort((left, right) => left.environmentId.localeCompare(right.environmentId));
+}
+
/**
* Changes whenever the older count can: any removal (by largest sequence, so a repeat counts), an
* older row that arrives or changes, or an old row arriving outside the set (unsettled). Threads
diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts
index fee99df172608..a52ad0dece8a0 100644
--- a/packages/client-runtime/src/state/orchestration.ts
+++ b/packages/client-runtime/src/state/orchestration.ts
@@ -75,6 +75,7 @@ export function createOrchestrationEnvironmentAtoms(
staleTimeMs: 300_000,
idleTtlMs: 300_000,
}),
+ // Coder: no native child work query; only Antigravity serves it (#28629).
stopWorkflow: createEnvironmentRpcCommand(runtime, {
label: "environment-data:orchestration:stop-workflow",
tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow,
diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts
index fbba7d1bd9dd4..d9368ef4058a4 100644
--- a/packages/client-runtime/src/state/orchestrationV2Projection.ts
+++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts
@@ -293,8 +293,9 @@ export function applyOrchestrationV2ProjectionEvent(
case "thread.provider-switched":
case "thread.guest-share-updated":
return { ...base, thread: event.payload };
- // Visited tracking is read state, not activity: skip the updatedAt bump.
+ // Visited tracking and tags are not activity: skip the updatedAt bump.
case "thread.visited":
+ case "thread.tags-updated":
case "thread.marked-unread":
return { ...projection, thread: event.payload };
case "run.created":
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index 68e3493a90026..e3f41951a6f2a 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -378,6 +378,26 @@ export function createServerEnvironmentAtoms(
Atom.withLabel(`environment-data:server:settings:${environmentId}`),
),
);
+ const hiddenThreadTagsAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get) => {
+ const settings = get(settingsValueAtom(environmentId));
+ if (settings?.hideTaggedThreads !== true) return null;
+ // Tags are trimmed on write; trim hand-edited entries so " private" still matches.
+ const tags = settings.hiddenThreadTags.map((tag) => tag.trim()).filter((tag) => tag !== "");
+ return tags.length > 0 ? tags : null;
+ }).pipe(
+ // Trimming makes a new array on every settings push; keep equal lists stable.
+ Atom.withEquality(
+ (a: ReadonlyArray | null, b: ReadonlyArray | null) =>
+ a === b ||
+ (a !== null &&
+ b !== null &&
+ a.length === b.length &&
+ a.every((tag, index) => tag === b[index])),
+ ),
+ Atom.withLabel(`environment-data:server:hidden-thread-tags:${environmentId}`),
+ ),
+ );
const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
Atom.withLabel(`environment-data:server:providers:${environmentId}`),
@@ -404,6 +424,7 @@ export function createServerEnvironmentAtoms(
idleTtlMs: 0,
}),
settingsValueAtom,
+ hiddenThreadTagsAtom,
providersValueAtom,
updateProvider: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:update-provider",
diff --git a/packages/client-runtime/src/state/serverConfigProjection.test.ts b/packages/client-runtime/src/state/serverConfigProjection.test.ts
index 99bdf27100f98..3ae2fe401abd8 100644
--- a/packages/client-runtime/src/state/serverConfigProjection.test.ts
+++ b/packages/client-runtime/src/state/serverConfigProjection.test.ts
@@ -1,9 +1,18 @@
-import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts";
+import {
+ DEFAULT_SERVER_SETTINGS,
+ ServerSettings,
+ type ServerConfig,
+ type ServerConfigStreamEvent,
+} from "@t3tools/contracts";
import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
import { applyServerConfigProjection, reuseUnchanged } from "./serverConfigProjection.ts";
+const encodeSettings = Schema.encodeSync(ServerSettings);
+const decodeSettings = Schema.decodeSync(ServerSettings);
+
describe("reuseUnchanged", () => {
const provider = (checkedAt: string, skills: ReadonlyArray) => ({
instanceId: "claude",
@@ -53,6 +62,22 @@ describe("applyServerConfigProjection settingsUpdated", () => {
expect(nextSettings.providerInstances.codex).toBe(settings.providerInstances.codex);
expect(nextSettings.providerInstances.claude.config.launchArgs).toBe("--verbose");
});
+
+ it("keeps the settings object when an update repeats the current settings", () => {
+ const wire = encodeSettings(DEFAULT_SERVER_SETTINGS);
+ const decode = () => decodeSettings(structuredClone(wire));
+ const settings = decode();
+ const current = Option.some({
+ config: { settings } as unknown as ServerConfig,
+ latestEvent: {} as ServerConfigStreamEvent,
+ source: "live" as const,
+ });
+ const next = applyServerConfigProjection(current, {
+ type: "settingsUpdated",
+ payload: { settings: decode() },
+ } as ServerConfigStreamEvent);
+ expect(Option.getOrThrow(next).config).toBe(Option.getOrThrow(current).config);
+ });
});
it("applies published-theme changes and removals without disturbing other config", () => {
diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts
index 7d388f889a37a..86a7f5cb8a575 100644
--- a/packages/client-runtime/src/state/serverConfigProjection.ts
+++ b/packages/client-runtime/src/state/serverConfigProjection.ts
@@ -1,4 +1,5 @@
import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts";
+import * as Equal from "effect/Equal";
import * as Option from "effect/Option";
export interface ServerConfigProjection {
@@ -44,6 +45,8 @@ export function reuseUnchanged(previous: unknown, next: T): T {
});
return (same ? previous : merged) as T;
}
+ // Decoded settings carry Durations, which are fresh instances on every push.
+ if (Equal.isEqual(next)) return (Equal.equals(previous, next) ? previous : next) as T;
if (Object.getPrototypeOf(next) !== Object.prototype) return next;
const before = previous as Record;
const after = next as Record;
@@ -104,14 +107,17 @@ export function applyServerConfigProjection(
source: "live",
}));
case "settingsUpdated":
- return Option.map(current, (projection) => ({
- config: {
- ...projection.config,
- settings: reuseUnchanged(projection.config.settings, event.payload.settings),
- },
- latestEvent: event,
- source: "live",
- }));
+ return Option.map(current, (projection) => {
+ const settings = reuseUnchanged(projection.config.settings, event.payload.settings);
+ return {
+ config:
+ settings === projection.config.settings
+ ? projection.config
+ : { ...projection.config, settings },
+ latestEvent: event,
+ source: "live",
+ };
+ });
case "environmentThemesUpdated":
return Option.map(current, (projection) => ({
config: {
diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts
index 19d674f8f9e54..3b9b210ca95fd 100644
--- a/packages/client-runtime/src/state/shell-sync.test.ts
+++ b/packages/client-runtime/src/state/shell-sync.test.ts
@@ -122,6 +122,190 @@ const makePersistenceHarness = Effect.fn("test.makeShellPersistenceHarness")(fun
});
describe("environment shell synchronization", () => {
+ it.effect.each([
+ { bufferSize: Infinity, expectedSequences: [51], existingRows: false },
+ { bufferSize: Infinity, expectedSequences: [51], existingRows: true },
+ // RpcClient defaults to a 16-event buffer, which splits larger server chunks.
+ { bufferSize: 16, expectedSequences: [17, 33, 49, 51], existingRows: false },
+ { bufferSize: 16, expectedSequences: [17, 33, 49, 51], existingRows: true },
+ ])("batches $bufferSize events (existing: $existingRows)", (input) =>
+ Effect.gen(function* () {
+ const { bufferSize, expectedSequences, existingRows } = input;
+ const events = yield* Queue.bounded(bufferSize);
+ let rowVisits = 0;
+ const rows = Array.from({ length: 50 }, (_, index) => ({
+ ...v2ThreadShell,
+ get id() {
+ rowVisits += 1;
+ return ThreadId.make(`thread-${index}`);
+ },
+ }));
+ const batchSnapshot = { ...LIVE_SHELL_SNAPSHOT, threads: existingRows ? rows : [] };
+ const client = {
+ [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
+ } as unknown as WsRpcProtocolClient;
+ const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
+ const activeSession = yield* SubscriptionRef.make>(
+ Option.some(session(client)),
+ );
+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target: TARGET,
+ state: supervisorState,
+ session: activeSession,
+ prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
+ const cache = Persistence.EnvironmentCacheStore.of({
+ loadShell: () => Effect.succeedNone,
+ saveShell: () => Effect.void,
+ loadThread: () => Effect.succeedNone,
+ saveThread: () => Effect.void,
+ removeThread: () => Effect.void,
+ loadServerConfig: () => Effect.succeedNone,
+ saveServerConfig: () => Effect.void,
+ loadVcsRefs: () => Effect.succeedNone,
+ saveVcsRefs: () => Effect.void,
+ removeVcsRefs: () => Effect.void,
+ clearVcsRefs: () => Effect.void,
+ clear: () => Effect.void,
+ });
+ const shellState = yield* makeEnvironmentShellState().pipe(
+ Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ Effect.provideService(Persistence.EnvironmentCacheStore, cache),
+ Effect.provideService(
+ ShellSnapshotLoader.ShellSnapshotLoader,
+ ShellSnapshotLoader.ShellSnapshotLoader.of({
+ load: () => Effect.succeedNone,
+ }),
+ ),
+ );
+ yield* SubscriptionRef.set(supervisorState, {
+ desired: true,
+ network: "online",
+ phase: "connected",
+ stage: null,
+ attempt: 1,
+ generation: 1,
+ lastFailure: null,
+ retryAt: null,
+ });
+ yield* Queue.offer(events, { kind: "snapshot", snapshot: batchSnapshot });
+ yield* Queue.offer(events, { kind: "synchronized" });
+ yield* SubscriptionRef.changes(shellState).pipe(
+ Stream.filter((state) => state.status === "live"),
+ Stream.runHead,
+ );
+
+ // Observe before publishing so no batch can arrive before the subscription.
+ const observed = yield* SubscriptionRef.changes(shellState).pipe(
+ Stream.drop(1),
+ Stream.takeUntil(
+ (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 51,
+ ),
+ Stream.runCollect,
+ Effect.forkScoped({ startImmediately: true }),
+ );
+ rowVisits = 0;
+ yield* Queue.offerAll(
+ events,
+ Array.from({ length: 50 }, (_, index) => ({
+ kind: "thread.updated" as const,
+ sequence: 2 + index,
+ location: "active" as const,
+ thread: {
+ ...v2ThreadShell,
+ get id() {
+ rowVisits += 1;
+ return ThreadId.make(`thread-${index}`);
+ },
+ title: "Updated",
+ },
+ })),
+ );
+ const states = yield* Fiber.join(observed);
+ if (existingRows) {
+ // Index each received batch once; distinct updates must not rescan every row.
+ expect(rowVisits).toBeLessThanOrEqual(50 * (expectedSequences.length + 10));
+ }
+ const snapshots = states.map((state) => Option.getOrThrow(state.snapshot));
+ expect(snapshots.map((snapshot) => snapshot.snapshotSequence)).toEqual(expectedSequences);
+ expect(snapshots.at(-1)!.threads.map((thread) => thread.id)).toEqual(
+ Array.from({ length: 50 }, (_, index) => `thread-${index}`),
+ );
+
+ const removedObserved = yield* SubscriptionRef.changes(shellState).pipe(
+ Stream.filter(
+ (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 52,
+ ),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ );
+ yield* Queue.offerAll(events, [
+ { kind: "future.event", sequence: 99 } as never,
+ {
+ kind: "thread.removed",
+ sequence: 52,
+ location: "active",
+ threadId: ThreadId.make("thread-0"),
+ },
+ {
+ kind: "thread.removed",
+ sequence: 51,
+ location: "active",
+ threadId: ThreadId.make("thread-1"),
+ },
+ ]);
+ const removedState = Option.getOrThrow(yield* Fiber.join(removedObserved));
+ expect(removedState.generation).toBe(states.at(-1)!.generation);
+ expect([...removedState.removedAt]).toEqual([[ThreadId.make("thread-0"), 52]]);
+
+ // An authoritative snapshot resets even a higher live cursor in the same batch.
+ const resetRow = { ...v2ThreadShell, id: ThreadId.make("reset-row") };
+ const resetObserved = yield* SubscriptionRef.changes(shellState).pipe(
+ Stream.filter(
+ (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 4,
+ ),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ );
+ yield* Queue.offerAll(events, [
+ {
+ kind: "thread.removed",
+ sequence: 52,
+ location: "active",
+ threadId: ThreadId.make("thread-0"),
+ },
+ {
+ kind: "snapshot",
+ snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 2, threads: [resetRow] },
+ },
+ { kind: "thread.removed", sequence: 2, location: "active", threadId: resetRow.id },
+ {
+ kind: "thread.updated",
+ sequence: 3,
+ location: "active",
+ thread: { ...resetRow, title: "After reset" },
+ },
+ { kind: "synchronized" },
+ {
+ kind: "thread.updated",
+ sequence: 4,
+ location: "active",
+ thread: { ...resetRow, title: "After completion" },
+ },
+ ]);
+ const resetState = Option.getOrThrow(yield* Fiber.join(resetObserved));
+ expect(resetState.status).toBe("live");
+ expect(resetState.generation).toBeGreaterThan(removedState.generation);
+ expect(resetState.removedAt.size).toBe(0);
+ expect(Option.getOrThrow(resetState.snapshot).threads).toEqual([
+ { ...resetRow, title: "After completion" },
+ ]);
+ }).pipe(Effect.scoped),
+ );
+
it.effect("skips unchanged shell writes, persists resets and flushes changed rows", () =>
Effect.gen(function* () {
const saved = yield* SubscriptionRef.make>([]);
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index 826576b19db7d..c38b10a2fc3e1 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -32,7 +32,8 @@ import type { RpcSession } from "../rpc/session.ts";
import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts";
import type { DeferredShellSnapshot } from "./shellPullRequests.ts";
import {
- applyShellStreamEvent,
+ applyShellStreamEvents,
+ type ShellStreamEvent,
mergeShellSnapshotProjects,
reuseUnchangedThreadShells,
sameThreadPullRequests,
@@ -212,10 +213,46 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
) {
const initial = yield* SubscriptionRef.get(state);
let waiting = yield* Ref.get(awaitingCompletion);
- /* oxlint-disable oxc/no-accumulating-spread -- This copies a fixed-shape state record, not a growing collection. */
let next = initial;
let receivedSnapshot = false;
+ let deltas: Array = [];
+ const flushDeltas = () => {
+ if (deltas.length > 0 && Option.isSome(next.snapshot)) {
+ let sequence = next.snapshot.value.snapshotSequence;
+ let removedAt: Map | undefined;
+ for (const delta of deltas) {
+ if (delta.sequence <= sequence) continue;
+ switch (delta.kind) {
+ case "thread.removed":
+ removedAt ??= new Map(next.removedAt);
+ removedAt.set(delta.threadId, delta.sequence);
+ break;
+ case "thread.updated":
+ case "project.updated":
+ case "project.removed":
+ break;
+ default:
+ continue;
+ }
+ sequence = delta.sequence;
+ }
+ next = {
+ ...next,
+ removedAt: removedAt ?? next.removedAt,
+ snapshot: Option.some(applyShellStreamEvents(next.snapshot.value, deltas)),
+ status: waiting ? "synchronizing" : "live",
+ error: Option.none(),
+ };
+ }
+ deltas = [];
+ };
for (const item of items) {
+ if (item.kind !== "snapshot" && item.kind !== "synchronized") {
+ deltas.push(item);
+ continue;
+ }
+ // Snapshots can reset the cursor; completion markers change the sync status.
+ flushDeltas();
if (item.kind === "synchronized") {
waiting = false;
if (Option.isSome(next.snapshot)) {
@@ -223,46 +260,24 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
}
continue;
}
- const authoritative =
- item.kind === "snapshot" && item.resolvedRepositoryIdentityRoots === undefined;
- const nextSnapshot =
- item.kind === "snapshot"
- ? mergeShellSnapshotProjects(
- Option.getOrNull(next.snapshot),
- item.snapshot,
- item.resolvedRepositoryIdentityRoots === undefined
- ? undefined
- : {
- resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots,
- },
- )
- : Option.match(next.snapshot, {
- onNone: () => null,
- onSome: (snapshot) =>
- item.sequence > snapshot.snapshotSequence
- ? applyShellStreamEvent(snapshot, item)
- : snapshot,
- });
- if (nextSnapshot === null) continue;
- receivedSnapshot ||= item.kind === "snapshot";
- const removal =
- item.kind === "thread.removed" &&
- Option.exists(next.snapshot, (snapshot) => item.sequence > snapshot.snapshotSequence)
- ? item
- : null;
+ const authoritative = item.resolvedRepositoryIdentityRoots === undefined;
+ const nextSnapshot = mergeShellSnapshotProjects(
+ Option.getOrNull(next.snapshot),
+ item.snapshot,
+ item.resolvedRepositoryIdentityRoots === undefined
+ ? undefined
+ : { resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots },
+ );
+ receivedSnapshot = true;
next = {
snapshot: Option.some(nextSnapshot),
status: waiting ? "synchronizing" : "live",
error: Option.none(),
generation: authoritative ? ++lastShellGeneration : next.generation,
- removedAt: authoritative
- ? EMPTY_REMOVALS
- : removal === null
- ? next.removedAt
- : new Map(next.removedAt).set(removal.threadId, removal.sequence),
+ removedAt: authoritative ? EMPTY_REMOVALS : next.removedAt,
};
}
- /* oxlint-enable oxc/no-accumulating-spread */
+ flushDeltas();
yield* Ref.set(awaitingCompletion, waiting);
if (next === initial) return;
if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) {
diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts
index 8be1ffd8e7a7a..f98a14c3ed32d 100644
--- a/packages/client-runtime/src/state/shellReducer.test.ts
+++ b/packages/client-runtime/src/state/shellReducer.test.ts
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vite-plus/test";
import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
import {
applyShellStreamEvent,
+ applyShellStreamEvents,
+ type ShellStreamEvent,
mergeShellSnapshotProjects,
reuseUnchangedThreadShells,
} from "./shellReducer.ts";
@@ -85,22 +87,31 @@ describe("applyShellStreamEvent", () => {
});
it("authoritative lower-sequence reset replaces client-ahead structural state", () => {
+ const unchanged = { ...v2Project, id: ProjectId.make("project-unchanged"), repositoryIdentity };
const previous = {
...v2ShellSnapshot,
snapshotSequence: 9,
- projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }],
+ projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }, unchanged],
threads: [{ ...v2ThreadShell, title: "Local-only thread" }],
};
const next = mergeShellSnapshotProjects(previous, {
...v2ShellSnapshot,
snapshotSequence: 3,
- projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }],
+ projects: [
+ { ...v2Project, title: "Server reset", repositoryIdentity: null },
+ { ...unchanged, scripts: [...unchanged.scripts] },
+ ],
threads: [{ ...v2ThreadShell, title: "Server thread" }],
});
expect(next.snapshotSequence).toBe(3);
expect(next.projects[0]?.title).toBe("Server reset");
expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
+ expect(next.projects[1]).toBe(unchanged);
+ expect(
+ mergeShellSnapshotProjects(next, { ...v2ShellSnapshot, projects: [...next.projects] })
+ .projects,
+ ).toBe(next.projects);
expect(next.threads[0]?.title).toBe("Server thread");
});
@@ -327,3 +338,95 @@ describe("reuseUnchangedThreadShells", () => {
expect(next.threads[0]?.pullRequests).toEqual([]);
});
});
+
+describe("applyShellStreamEvents", () => {
+ const row = (id: string) => ({ ...v2ThreadShell, id: ThreadId.make(id) });
+ const a = row("a");
+ const b = row("b");
+ const unchanged = row("unchanged");
+ const archived = row("archived");
+ const oldArchive = row("old-archive");
+ const added = row("new");
+ const update = (
+ sequence: number,
+ thread: typeof v2ThreadShell,
+ location: "active" | "archive" = "active",
+ ): ShellStreamEvent => ({ kind: "thread.updated", sequence, location, thread });
+ const remove = (sequence: number, thread: typeof v2ThreadShell): ShellStreamEvent => ({
+ kind: "thread.removed",
+ sequence,
+ location: "active",
+ threadId: thread.id,
+ });
+
+ it("matches sequential mixed deltas, including remove/reinsert order and stale events", () => {
+ const otherProject = { ...v2Project, id: ProjectId.make("other-project") };
+ const seed = {
+ ...v2ShellSnapshot,
+ snapshotSequence: 4,
+ projects: [{ ...v2Project, repositoryIdentity }, otherProject],
+ threads: [a, b, unchanged],
+ archivedThreads: [archived, oldArchive],
+ };
+ const events: Array = [
+ update(5, { ...b, title: "Renamed" }),
+ remove(5, a),
+ remove(6, a),
+ update(7, a),
+ update(8, b, "archive"),
+ update(9, archived),
+ update(10, oldArchive, "archive"),
+ update(11, added),
+ remove(12, { ...a, id: ThreadId.make("missing") }),
+ remove(11, added),
+ remove(13, added),
+ update(14, added),
+ update(15, { ...archived }),
+ { kind: "project.updated", sequence: 16, project: { ...v2Project, title: "Updated" } },
+ { kind: "project.updated", sequence: 17, project: { ...v2Project, workspaceRoot: "/moved" } },
+ { kind: "project.removed", sequence: 18, projectId: v2Project.id },
+ { kind: "project.updated", sequence: 19, project: v2Project },
+ ];
+ const sequential = events.reduce(applyShellStreamEvent, seed);
+ const next = applyShellStreamEvents(seed, events);
+ expect(next).toEqual(sequential);
+ expect(next.threads.map((row) => row.id)).toEqual([unchanged.id, a.id, archived.id, added.id]);
+ expect(next.threads[0]).toBe(unchanged);
+ expect(next.threads[2]).toBe(archived);
+ expect(next.projects[0]).toBe(otherProject);
+ expect(next.archivedThreads).toEqual([]);
+ expect(seed.threads).toEqual([a, b, unchanged]);
+ expect(seed.archivedThreads).toEqual([archived, oldArchive]);
+ // Identity retention must use the latest project in this batch.
+ expect(applyShellStreamEvents(seed, events.slice(0, 14)).projects[0]?.repositoryIdentity).toBe(
+ repositoryIdentity,
+ );
+ expect(
+ applyShellStreamEvents(seed, events.slice(0, 15)).projects[0]?.repositoryIdentity,
+ ).toBeNull();
+ });
+
+ it("keeps collection identities for equal rows and rejects stale deltas without indexing", () => {
+ const seed = { ...v2ShellSnapshot, threads: [a, b], snapshotSequence: 4 };
+ const next = applyShellStreamEvents(seed, [update(5, { ...a }), update(6, { ...b })]);
+ expect(next).toEqual(
+ [update(5, { ...a }), update(6, { ...b })].reduce(applyShellStreamEvent, seed),
+ );
+ expect(next.threads).toBe(seed.threads);
+ expect(next.projects).toBe(seed.projects);
+ expect(next.archivedThreads).toBe(seed.archivedThreads);
+ expect(next.snapshotSequence).toBe(6);
+ expect(applyShellStreamEvents(seed, [remove(3, a), update(4, b)])).toBe(seed);
+ expect(applyShellStreamEvents(seed, [])).toBe(seed);
+ });
+
+ it("does not advance the cursor for an unknown future event", () => {
+ const events = [
+ { kind: "future.event", sequence: 99 } as never,
+ update(1, { ...a, title: "Changed" }),
+ ];
+ expect(applyShellStreamEvents(v2ShellSnapshot, events)).toEqual(
+ events.reduce(applyShellStreamEvent, v2ShellSnapshot),
+ );
+ });
+});
diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts
index 2d9647e85fc1c..8491143996908 100644
--- a/packages/client-runtime/src/state/shellReducer.ts
+++ b/packages/client-runtime/src/state/shellReducer.ts
@@ -1,14 +1,15 @@
import {
+ OrchestrationProjectShell,
OrchestrationV2ThreadShell,
RepositoryIdentity,
ThreadPullRequestLink,
- type OrchestrationProjectShell,
type OrchestrationV2ShellSnapshot,
type OrchestrationV2ShellStreamItem,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell);
+const sameProjectShell = Schema.toEquivalence(OrchestrationProjectShell);
const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink)));
const sameRepositoryIdentity = Schema.toEquivalence(
Schema.UndefinedOr(Schema.NullOr(RepositoryIdentity)),
@@ -116,16 +117,16 @@ export function mergeShellSnapshotProjects(
}
const previousById = new Map(previous.projects.map((project) => [project.id, project] as const));
- return {
- ...next,
- projects: next.projects.map((project) => {
- const prior = previousById.get(project.id);
- if (resolvedRootSet?.has(project.workspaceRoot) === true) {
- return project;
- }
- return retainRepositoryIdentity(prior, project);
- }),
- };
+ // Project atoms and shell persistence compare by reference, so unchanged projects keep their objects.
+ const projects = next.projects.map((project) => {
+ const prior = previousById.get(project.id);
+ const merged = retainRepositoryIdentity(prior, project);
+ return prior !== undefined && sameProjectShell(prior, merged) ? prior : merged;
+ });
+ const unchanged =
+ projects.length === previous.projects.length &&
+ projects.every((project, index) => project === previous.projects[index]);
+ return { ...next, projects: unchanged ? previous.projects : projects };
}
export function applyShellStreamEvent(
@@ -191,3 +192,84 @@ export function applyShellStreamEvent(
return snapshot;
}
}
+
+/** Index a collection lazily and materialize it only if a delta changes its rows. */
+function batchRows(rows: ReadonlyArray) {
+ let byId: Map | undefined;
+ let changed = false;
+ const index = () => {
+ if (byId === undefined) {
+ byId = new Map();
+ for (const row of rows) byId.set(row.id, row);
+ }
+ return byId;
+ };
+ return {
+ get: (id: T["id"]) => index().get(id),
+ set: (row: T) => {
+ index().set(row.id, row);
+ changed = true;
+ },
+ remove: (id: T["id"]) => {
+ if (index().delete(id)) changed = true;
+ },
+ finish: () => (changed ? Array.from(index().values()) : rows),
+ };
+}
+
+export type ShellStreamEvent = Exclude<
+ OrchestrationV2ShellStreamItem,
+ { readonly kind: "snapshot" } | { readonly kind: "synchronized" }
+>;
+
+/** Apply ordered deltas with one index and at most one new array per collection. */
+export function applyShellStreamEvents(
+ snapshot: OrchestrationV2ShellSnapshot,
+ events: ReadonlyArray,
+): OrchestrationV2ShellSnapshot {
+ // Avoid indexing the whole shell for the common single-event delivery.
+ if (events.length === 1) return applyShellStreamEvent(snapshot, events[0]!);
+ const projects = batchRows(snapshot.projects);
+ const threads = batchRows(snapshot.threads);
+ const archivedThreads = batchRows(snapshot.archivedThreads);
+ let sequence = snapshot.snapshotSequence;
+ for (const event of events) {
+ if (event.sequence <= sequence) continue;
+ switch (event.kind) {
+ case "project.updated":
+ projects.set(retainRepositoryIdentity(projects.get(event.project.id), event.project));
+ break;
+ case "project.removed":
+ projects.remove(event.projectId);
+ break;
+ case "thread.updated": {
+ const id = event.thread.id;
+ if (event.location === "active") {
+ const previous = threads.get(id);
+ if (previous !== undefined && sameThreadShell(previous, event.thread)) break;
+ threads.set(event.thread);
+ } else {
+ threads.remove(id);
+ }
+ // Legacy archive deltas remove rows from the shell; the archive is queried separately.
+ archivedThreads.remove(id);
+ break;
+ }
+ case "thread.removed":
+ threads.remove(event.threadId);
+ archivedThreads.remove(event.threadId);
+ break;
+ default:
+ continue;
+ }
+ sequence = event.sequence;
+ }
+ if (sequence === snapshot.snapshotSequence) return snapshot;
+ return {
+ ...snapshot,
+ snapshotSequence: sequence,
+ projects: projects.finish(),
+ threads: threads.finish(),
+ archivedThreads: archivedThreads.finish(),
+ };
+}
diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts
new file mode 100644
index 0000000000000..5bdd434388ec6
--- /dev/null
+++ b/packages/client-runtime/src/state/subagentDisplay.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { subagentDetailPreview } from "./subagentDisplay.js";
+
+describe("subagentDetailPreview", () => {
+ it("prefers progress for live work and results for settled work", () => {
+ const details = { progress: "Reading files", result: "Found two\n problems" };
+ expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files");
+ expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems");
+ expect(
+ subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }),
+ ).toBe("Last progress");
+ expect(subagentDetailPreview({ status: "pending" })).toBeNull();
+ });
+});
+
+it.each([279, 280, 281])("preserves the preview boundary at %i characters", (length) => {
+ const result = "x".repeat(length);
+ expect(subagentDetailPreview({ status: "completed", result })).toBe(
+ length > 280 ? "x".repeat(280) + "…" : result,
+ );
+});
+
+it("normalizes Unicode whitespace and ignores a large hidden suffix", () => {
+ const progress = " \u00a0Reading\tfiles\n" + "x".repeat(1_000_000);
+ expect(subagentDetailPreview({ status: "running", progress })).toBe(
+ "Reading files " + "x".repeat(266) + "…",
+ );
+ expect(
+ subagentDetailPreview({ status: "completed", result: "x".repeat(280) + " \n\u2028" }),
+ ).toBe("x".repeat(280));
+ expect(subagentDetailPreview({ status: "completed", result: "x".repeat(279) + " \n y" })).toBe(
+ "x".repeat(279) + "…",
+ );
+ expect(
+ subagentDetailPreview({ status: "running", progress: "\uFEFF\u2029", result: " Done " }),
+ ).toBe("Done");
+ expect(subagentDetailPreview({ status: "failed", result: "x".repeat(279) + "😀" })).toBe(
+ "x".repeat(279) + "\uD83D…",
+ );
+});
diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts
index 295e557fc8ff8..124c45e264f79 100644
--- a/packages/client-runtime/src/state/subagentDisplay.ts
+++ b/packages/client-runtime/src/state/subagentDisplay.ts
@@ -116,10 +116,23 @@ export function subagentDetailPreview(input: {
readonly result?: string | null | undefined;
readonly progress?: string | null | undefined;
}): string | null {
- const result = input.result?.trim();
- const progress = input.progress?.trim();
- const detail =
- (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || "";
- const compact = detail.replace(/\s+/gu, " ");
- return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null;
+ const terminal = isTerminalSubagentStatus(input.status);
+ const preferred = terminal ? input.result : input.progress;
+ const fallback = terminal ? input.progress : input.result;
+ const detail = preferred?.trimStart() || fallback?.trimStart() || "";
+ if (detail.length <= 280) return detail.trimEnd().replace(/\s+/gu, " ") || null;
+ const whitespace = /\s+/uy;
+ let compact = "";
+ for (let index = 0; index < detail.length; index += 1) {
+ whitespace.lastIndex = index;
+ const gap = whitespace.exec(detail);
+ if (gap) {
+ index += gap[0].length;
+ if (index === detail.length) break;
+ if (compact.length > 0) compact += " ";
+ }
+ compact += detail[index];
+ if (compact.length > 280) return `${compact.slice(0, 280).trimEnd()}…`;
+ }
+ return compact || null;
}
diff --git a/packages/client-runtime/src/state/threadCheckpoints.test.ts b/packages/client-runtime/src/state/threadCheckpoints.test.ts
new file mode 100644
index 0000000000000..7eb85f54b82af
--- /dev/null
+++ b/packages/client-runtime/src/state/threadCheckpoints.test.ts
@@ -0,0 +1,257 @@
+import {
+ CheckpointId,
+ CheckpointRef,
+ CheckpointScopeId,
+ MessageId,
+ NodeId,
+ RunId,
+ ThreadId,
+ type OrchestrationV2Checkpoint,
+ type OrchestrationV2ConversationMessage,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
+
+import { deriveThreadCheckpointSummaries } from "./threadCheckpoints.ts";
+
+function checkpoint(
+ run: string,
+ overrides: Partial = {},
+): OrchestrationV2Checkpoint {
+ return {
+ id: CheckpointId.make(`checkpoint-${run}`),
+ threadId: ThreadId.make("thread"),
+ scopeId: CheckpointScopeId.make("scope"),
+ runId: RunId.make(run),
+ nodeId: NodeId.make(`node-${run}`),
+ parentCheckpointId: null,
+ ordinalWithinScope: 1,
+ appRunOrdinal: 1,
+ ref: CheckpointRef.make(`ref-${run}`),
+ status: "ready",
+ files: [],
+ capturedAt: DateTime.makeUnsafe("2026-10-26T09:30:00.123+11:00"),
+ ...overrides,
+ };
+}
+
+function message(
+ run: string,
+ id: string,
+ overrides: Partial = {},
+): OrchestrationV2ConversationMessage {
+ return {
+ id: MessageId.make(id),
+ threadId: ThreadId.make("thread"),
+ runId: RunId.make(run),
+ nodeId: null,
+ role: "assistant",
+ text: "Answer",
+ attachments: [],
+ streaming: false,
+ createdBy: "agent",
+ creationSource: "provider",
+ createdAt: DateTime.makeUnsafe("2026-10-26T00:00:00.000Z"),
+ updatedAt: DateTime.makeUnsafe("2026-10-26T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+describe("deriveThreadCheckpointSummaries", () => {
+ it("preserves checkpoint order, status, file references and ISO dates while filtering null keys", () => {
+ const files = Object.freeze([
+ { path: "src/one.ts", kind: "modified", additions: 10, deletions: 2 },
+ ]);
+ const eligible = ["stale", "error", "missing", "ready"].map((status, index) =>
+ checkpoint(`run-${index}`, {
+ status: status as OrchestrationV2Checkpoint["status"],
+ appRunOrdinal: 4 - index,
+ files,
+ }),
+ );
+ const checkpoints = Object.freeze([
+ checkpoint("no-ordinal", { appRunOrdinal: null }),
+ ...eligible.slice(0, 2),
+ checkpoint("no-run", { runId: null }),
+ ...eligible.slice(2),
+ ]);
+ const summaries = deriveThreadCheckpointSummaries({ checkpoints, messages: [] });
+
+ expect(summaries).toEqual(
+ eligible.map((entry) => ({
+ checkpointId: entry.id,
+ scopeId: entry.scopeId,
+ runId: entry.runId,
+ checkpointTurnCount: entry.appRunOrdinal,
+ checkpointRef: entry.ref,
+ status: entry.status,
+ files,
+ assistantMessageId: null,
+ completedAt: "2026-10-25T22:30:00.123Z",
+ })),
+ );
+ for (const summary of summaries) expect(summary.files).toBe(files);
+ });
+
+ it("selects the last assistant in array order across interleaved runs and duplicate checkpoints", () => {
+ const newer = DateTime.makeUnsafe("2026-10-26T01:00:00.000Z");
+ const messages = Object.freeze([
+ message("a", "newer-timestamp", { createdAt: newer, updatedAt: newer }),
+ message("b", "b-first"),
+ message("a", "last-in-array"),
+ message("b", "b-last"),
+ message("a", "user-tail", { role: "user" }),
+ message("a", "system-tail", { role: "system" }),
+ message("a", "unscoped-tail", { runId: null }),
+ ]);
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("b"), checkpoint("a"), checkpoint("b", { appRunOrdinal: 3 })],
+ messages,
+ });
+
+ expect(summaries.map((summary) => summary.assistantMessageId)).toEqual([
+ "b-last",
+ "last-in-array",
+ "b-last",
+ ]);
+ expect(messages.map((entry) => entry.id)).toEqual([
+ "newer-timestamp",
+ "b-first",
+ "last-in-array",
+ "b-last",
+ "user-tail",
+ "system-tail",
+ "unscoped-tail",
+ ]);
+ });
+
+ it("returns null for absent runs and runs containing only user or system messages", () => {
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("absent"), checkpoint("user-only"), checkpoint("system-only")],
+ messages: [
+ message("other", "other-assistant"),
+ message("user-only", "user", { role: "user" }),
+ message("system-only", "system", { role: "system" }),
+ ],
+ });
+ expect(summaries.map((summary) => summary.assistantMessageId)).toEqual([null, null, null]);
+ });
+
+ it.each([null, undefined])("does not fall back past a last assistant with ID %s", (id) => {
+ const { id: _id, ...withoutId } = message("a", "last");
+ // Preserve the runtime fallback even for legacy objects outside the decoded schema.
+ const last = (
+ id === undefined ? withoutId : { ...withoutId, id }
+ ) as OrchestrationV2ConversationMessage;
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("a"), checkpoint("a")],
+ messages: [message("a", "earlier-valid-id"), last],
+ });
+ expect(summaries.map((summary) => summary.assistantMessageId)).toEqual([null, null]);
+ });
+
+ it.each([
+ { checkpoints: [] },
+ {
+ checkpoints: [
+ checkpoint("no-ordinal", { appRunOrdinal: null }),
+ checkpoint("no-run", { runId: null }),
+ ],
+ },
+ ])("does not read messages with no eligible checkpoints: %j", ({ checkpoints }) => {
+ expect(
+ deriveThreadCheckpointSummaries({
+ checkpoints,
+ get messages(): ReadonlyArray {
+ throw new Error("Ineligible checkpoints must not read message history");
+ },
+ }),
+ ).toEqual([]);
+ });
+
+ it("rebuilds associations for message and checkpoint replacements without retaining stale IDs", () => {
+ const original = message("a", "original");
+ const checkpoints = [checkpoint("a")];
+ const first = deriveThreadCheckpointSummaries({ checkpoints, messages: [original] });
+ expect(first[0]?.assistantMessageId).toBe("original");
+ expect(
+ deriveThreadCheckpointSummaries({
+ checkpoints,
+ messages: [original, message("a", "replacement")],
+ })[0]?.assistantMessageId,
+ ).toBe("replacement");
+ expect(
+ deriveThreadCheckpointSummaries({ checkpoints, messages: [] })[0]?.assistantMessageId,
+ ).toBeNull();
+ expect(
+ deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("b")],
+ messages: [original, message("b", "new-checkpoint")],
+ })[0]?.assistantMessageId,
+ ).toBe("new-checkpoint");
+ expect(first[0]?.assistantMessageId).toBe("original");
+ });
+
+ it("stops at the newest requested assistant when only tail checkpoints are needed", () => {
+ const history = Array.from({ length: 1_000 }, (_, index) => ({
+ ...message(`old-${index}`, `old-${index}`),
+ get runId(): RunId {
+ throw new Error("Tail checkpoints must not traverse older history");
+ },
+ }));
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("latest"), checkpoint("latest")],
+ messages: [...history, message("latest", "latest-answer")],
+ });
+ expect(summaries.map((summary) => summary.assistantMessageId)).toEqual([
+ "latest-answer",
+ "latest-answer",
+ ]);
+ });
+
+ it("does not index assistant IDs from unrelated runs", () => {
+ const unrelated = Array.from({ length: 1_000 }, (_, index) => ({
+ ...message(`other-${index}`, `other-${index}`),
+ get id(): MessageId {
+ throw new Error("Unrequested runs must not be indexed");
+ },
+ }));
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: [checkpoint("requested")],
+ messages: [message("requested", "requested-answer"), ...unrelated],
+ });
+ expect(summaries[0]?.assistantMessageId).toBe("requested-answer");
+ });
+
+ it.each([20, 100, 1_000])(
+ "visits message run metadata at most once for %i eligible checkpoints",
+ (count) => {
+ let metadataVisits = 0;
+ const messages = Array.from({ length: count }, (_, index) => [
+ message(`run-${index}`, `user-${index}`, { role: "user" }),
+ message(`run-${index}`, `assistant-${index}`),
+ ])
+ .flat()
+ .concat(message("active-run", "active-assistant"))
+ .map((entry) => ({
+ ...entry,
+ get runId() {
+ metadataVisits++;
+ return entry.runId;
+ },
+ get text(): string {
+ throw new Error("Checkpoint summaries must not read message bodies");
+ },
+ }));
+ const summaries = deriveThreadCheckpointSummaries({
+ checkpoints: Array.from({ length: count }, (_, index) => checkpoint(`run-${index}`)),
+ messages,
+ });
+
+ expect(summaries.map((summary) => summary.assistantMessageId)).toEqual(
+ Array.from({ length: count }, (_, index) => `assistant-${index}`),
+ );
+ expect(metadataVisits).toBeLessThanOrEqual(messages.length);
+ },
+ );
+});
diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts
index ee2fe45d9c756..c3efae22b11bc 100644
--- a/packages/client-runtime/src/state/threadCheckpoints.ts
+++ b/packages/client-runtime/src/state/threadCheckpoints.ts
@@ -31,12 +31,35 @@ export interface ThreadCheckpointSummary {
export function deriveThreadCheckpointSummaries(
projection: Pick,
): ReadonlyArray {
+ let assistantMessageIdsByRun: Map | undefined;
+ let nextMessageIndex = -1;
return projection.checkpoints.flatMap((checkpoint) => {
if (checkpoint.appRunOrdinal === null || checkpoint.runId === null) return [];
- const assistantMessageId =
- projection.messages.findLast(
- (message) => message.runId === checkpoint.runId && message.role === "assistant",
- )?.id ?? null;
+ const messages = projection.messages;
+ if (assistantMessageIdsByRun === undefined) {
+ assistantMessageIdsByRun = new Map();
+ for (const entry of projection.checkpoints) {
+ if (entry.appRunOrdinal !== null && entry.runId !== null) {
+ assistantMessageIdsByRun.set(entry.runId, undefined);
+ }
+ }
+ nextMessageIndex = messages.length - 1;
+ }
+ // Resume the reverse scan only as far as this checkpoint needs. Recent
+ // checkpoints must not force a traversal of unrelated older history.
+ while (nextMessageIndex >= 0 && assistantMessageIdsByRun.get(checkpoint.runId) === undefined) {
+ const message = messages[nextMessageIndex--]!;
+ const { runId, role } = message;
+ if (
+ role === "assistant" &&
+ runId !== null &&
+ assistantMessageIdsByRun.has(runId) &&
+ assistantMessageIdsByRun.get(runId) === undefined
+ ) {
+ assistantMessageIdsByRun.set(runId, message.id ?? null);
+ }
+ }
+ const assistantMessageId = assistantMessageIdsByRun.get(checkpoint.runId) ?? null;
return [
{
checkpointId: checkpoint.id,
diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts
index 9daa166e0b63d..ed60963ce7ef2 100644
--- a/packages/client-runtime/src/state/threadExecution.test.ts
+++ b/packages/client-runtime/src/state/threadExecution.test.ts
@@ -26,6 +26,7 @@ import {
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
+ isBackgroundWorkStopPending,
presentPendingBackgroundWork,
presentProviderGoal,
presentRunStop,
@@ -472,6 +473,35 @@ describe("provider-reported model selection", () => {
});
});
+describe("isBackgroundWorkStopPending", () => {
+ const tasks = [{ taskId: "dev-server" }];
+ const sent = { threadKey: "env:thread", taskIds: ["dev-server"], retryAt: null };
+ const accepted = { ...sent, retryAt: 10_000 };
+
+ it("stays pending after acceptance while a stopped task is still listed", () => {
+ expect(isBackgroundWorkStopPending(sent, "env:thread", tasks, false)).toBe(true);
+ expect(isBackgroundWorkStopPending(accepted, "env:thread", tasks, false)).toBe(true);
+ });
+
+ it("ends once the stopped tasks leave, even if new work starts", () => {
+ expect(isBackgroundWorkStopPending(accepted, "env:thread", [], false)).toBe(false);
+ expect(isBackgroundWorkStopPending(accepted, "env:thread", [{ taskId: "next" }], false)).toBe(
+ false,
+ );
+ });
+
+ it("allows retry at the deadline even if a stopped task remains", () => {
+ const request = { ...accepted, retryAt: 10_000 };
+ expect(isBackgroundWorkStopPending(request, "env:thread", tasks, false)).toBe(true);
+ expect(isBackgroundWorkStopPending(request, "env:thread", tasks, true)).toBe(false);
+ });
+
+ it("only applies to the thread that sent the Stop", () => {
+ expect(isBackgroundWorkStopPending(null, "env:thread", tasks, false)).toBe(false);
+ expect(isBackgroundWorkStopPending(sent, "env:other", tasks, false)).toBe(false);
+ });
+});
+
describe("presentProviderGoal", () => {
it("rolls token counts that round to 1000k over to 1m", () => {
const usage = (tokensUsed: number) =>
diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts
index 3420c25de6cf2..b6151c7c875be 100644
--- a/packages/client-runtime/src/state/threadExecution.ts
+++ b/packages/client-runtime/src/state/threadExecution.ts
@@ -378,6 +378,26 @@ function joinWithAnd(parts: ReadonlyArray): string {
return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
}
+// The Claude adapter forces a stuck query closed after 10 seconds.
+export const BACKGROUND_WORK_STOP_RETRY_AFTER_MS = 10_000;
+
+export interface BackgroundWorkStopRequest {
+ readonly threadKey: string;
+ readonly taskIds: ReadonlyArray;
+ readonly retryAt: number | null;
+}
+
+/** Acceptance does not end the tasks, so an accepted Stop stays pending while they are listed. */
+export function isBackgroundWorkStopPending(
+ request: BackgroundWorkStopRequest | null,
+ threadKey: string,
+ tasks: ReadonlyArray>,
+ retryExpired: boolean,
+): boolean {
+ if (request === null || request.threadKey !== threadKey || retryExpired) return false;
+ return request.retryAt === null || tasks.some((task) => request.taskIds.includes(task.taskId));
+}
+
export function presentPendingBackgroundWork(
tasks: ReadonlyArray,
): PendingBackgroundWorkPresentation | null {
@@ -413,12 +433,10 @@ export function presentPendingBackgroundWork(
if (items.length === 1 && only !== undefined) {
const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
const named = only.label !== noun;
- const title = waiting
- ? named
- ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}`
- : `Waiting on a ${noun}`
- : named
- ? `Running: ${only.label}`
+ const title = named
+ ? `${waiting ? "Waiting" : "Running"} · ${only.label}`
+ : waiting
+ ? `Waiting on a ${noun}`
: `Running a ${noun}`;
return { title, items, waiting };
}
diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts
index de9cd822398d0..ba5e119438290 100644
--- a/packages/client-runtime/src/state/threadInbox.ts
+++ b/packages/client-runtime/src/state/threadInbox.ts
@@ -112,7 +112,7 @@ type ThreadKeyInput = Pick;
* run can end moments before its continuation starts, so clients wait this long
* before treating the thread as done.
*/
-export const WORKING_EXIT_GRACE_MS = 2_000;
+const WORKING_EXIT_GRACE_MS = 2_000;
/**
* Keep one at module scope so placement survives routes that unmount the list. Call
@@ -227,7 +227,8 @@ export function createInboxReturnTracker() {
}
}
schedule();
- const observedWorking = new Map([...states].map(([key, state]) => [key, state.working]));
+ const observedWorking = new Map();
+ states.forEach((state, key) => observedWorking.set(key, state.working));
const observedReturns = new Map(returns);
return {
isWorking: (thread) => observedWorking.get(keyOf(thread)) ?? isThreadWorking(thread),
diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts
index b8d180e3a0ff0..d9f2cca584fbc 100644
--- a/packages/client-runtime/src/state/threadSearch.test.ts
+++ b/packages/client-runtime/src/state/threadSearch.test.ts
@@ -191,6 +191,10 @@ it("ranks recent threads by this client's own latest open", () => {
threadSearchSubject({ ...thread, id: ThreadId.make("thread-unrecorded"), opens: null }, null)
.opens,
).toEqual({ openedAt: 6_000, score: 1, picks: [] });
+ // A subject built before the open does not hide it.
+ claimThreadOpen(envA, "thread-local", 7_000);
+ expect(threadSearchSubject(thread, null).opens?.openedAt).toBe(7_000);
+ expect(threadSearchSubject(thread, "Project").projectTitle).toBe("Project");
});
it("counts recency from what the user wrote, else from when the thread began", () => {
diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts
index caaebb016ecd4..23794cec2c33e 100644
--- a/packages/client-runtime/src/state/threadSearch.ts
+++ b/packages/client-runtime/src/state/threadSearch.ts
@@ -29,6 +29,7 @@ const sentThreadOpens = new Map();
// When this client last opened each thread, sent or not, so recent threads
// follow quick switches the server merges.
const localThreadOpens = new Map();
+let localThreadOpensVersion = 0;
/**
* Records that this client opened a thread, and says whether to send the open
* to the server now.
@@ -36,6 +37,7 @@ const localThreadOpens = new Map();
export function claimThreadOpen(environmentId: EnvironmentId, threadId: string, now: number) {
const key = `${environmentId}:${threadId}`;
localThreadOpens.set(key, now);
+ localThreadOpensVersion += 1;
if (now - (sentThreadOpens.get(key) ?? Number.NEGATIVE_INFINITY) < THREAD_OPEN_DEDUPE_MS) {
return false;
}
@@ -208,6 +210,41 @@ export function threadSearchSubject(
thread: ThreadSearchShell,
projectTitle: string | null | undefined,
environmentLabel?: string | null,
+): ThreadSearchSubject {
+ // A keystroke ranks the same shells again, so a subject is built once per shell.
+ const cached = subjectCache.get(thread);
+ if (
+ cached !== undefined &&
+ cached.projectTitle === projectTitle &&
+ cached.environmentLabel === environmentLabel &&
+ cached.opensVersion === localThreadOpensVersion
+ ) {
+ return cached.subject;
+ }
+ const subject = buildThreadSearchSubject(thread, projectTitle, environmentLabel);
+ subjectCache.set(thread, {
+ projectTitle,
+ environmentLabel,
+ opensVersion: localThreadOpensVersion,
+ subject,
+ });
+ return subject;
+}
+
+const subjectCache = new WeakMap<
+ ThreadSearchShell,
+ {
+ readonly projectTitle: string | null | undefined;
+ readonly environmentLabel: string | null | undefined;
+ readonly opensVersion: number;
+ readonly subject: ThreadSearchSubject;
+ }
+>();
+
+function buildThreadSearchSubject(
+ thread: ThreadSearchShell,
+ projectTitle: string | null | undefined,
+ environmentLabel: string | null | undefined,
): ThreadSearchSubject {
// What the user wrote, not agent wakes or task notices in the user role. A
// thread the user never wrote in counts from its creation; an older server
diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts
index a6aef329882ae..9306221ae257b 100644
--- a/packages/client-runtime/src/state/threadShell.test.ts
+++ b/packages/client-runtime/src/state/threadShell.test.ts
@@ -48,7 +48,11 @@ function makeHarness(environmentIds: ReadonlyArray = [environment
registry: AtomRegistry.make(),
snapshotAtom,
catalogValueAtom,
- threads: createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom }),
+ threads: createEnvironmentThreadShellAtoms({
+ catalogValueAtom,
+ snapshotAtom,
+ hiddenThreadTagsAtom: () => Atom.make(null),
+ }),
};
}
diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts
index 60c454469b551..f5ced761c5665 100644
--- a/packages/client-runtime/src/state/threadShell.ts
+++ b/packages/client-runtime/src/state/threadShell.ts
@@ -16,6 +16,7 @@ import { Atom } from "effect/reactivity";
import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
import { presentThreadShell, threadRuntimeIsActive } from "./models.ts";
+import { hiddenThreadKeys, NO_HIDDEN_THREADS } from "./threadTagFilter.ts";
import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
import {
arrayElementsEqual,
@@ -204,6 +205,10 @@ export function createEnvironmentThreadShellAtoms(input: {
readonly snapshotAtom: (
environmentId: EnvironmentId,
) => Atom.Atom;
+ /** Tags whose threads display lists leave out; null while the filter is off. */
+ readonly hiddenThreadTagsAtom: (
+ environmentId: EnvironmentId,
+ ) => Atom.Atom | null>;
}) {
// Point reads and aggregate lists share values without keeping an atom alive
// for every listed thread. Replaced source objects can be collected.
@@ -589,12 +594,67 @@ export function createEnvironmentThreadShellAtoms(input: {
return previousThreadShells;
}).pipe(Atom.withLabel("environment-thread-shell-list"));
+ // Point reads and threadShellsAtom stay complete, so code that asks whether a thread
+ // exists (outboxes, drafts, removals) never mistakes a hidden thread for a missing one.
+ let previousHiddenThreadKeys: ReadonlySet = NO_HIDDEN_THREADS;
+ const hiddenThreadKeysAtom = Atom.make((get) => {
+ const environments = Array.from(
+ enabledEnvironmentIds(get(input.catalogValueAtom)),
+ (environmentId) => ({
+ environmentId,
+ hiddenTags: get(input.hiddenThreadTagsAtom(environmentId)),
+ }),
+ );
+ // Read shells only while some environment hides tags, so streaming ticks cost nothing otherwise.
+ if (!environments.some((environment) => environment.hiddenTags !== null)) {
+ previousHiddenThreadKeys = NO_HIDDEN_THREADS;
+ return NO_HIDDEN_THREADS;
+ }
+ const next = hiddenThreadKeys(
+ environments.map((environment) => ({
+ ...environment,
+ threads: get(environmentThreadsAtom(environment.environmentId)),
+ })),
+ );
+ if (
+ next.size === previousHiddenThreadKeys.size &&
+ [...next].every((key) => previousHiddenThreadKeys.has(key))
+ ) {
+ return previousHiddenThreadKeys;
+ }
+ previousHiddenThreadKeys = next;
+ return next;
+ }).pipe(Atom.withLabel("environment-hidden-thread-keys"));
+
+ const anyEnvironmentHidingAtom = Atom.make((get) =>
+ Array.from(enabledEnvironmentIds(get(input.catalogValueAtom))).some(
+ (environmentId) => get(input.hiddenThreadTagsAtom(environmentId)) !== null,
+ ),
+ ).pipe(Atom.withLabel("environment-any-hiding-thread-tags"));
+
+ /** threadShellsAtom without the threads the tag filter hides, for pickers and palettes. */
+ let previousVisibleThreadShells: ReadonlyArray = [];
+ const visibleThreadShellsAtom = Atom.make((get) => {
+ const threads = get(threadShellsAtom);
+ const hidden = get(hiddenThreadKeysAtom);
+ if (hidden.size === 0) return threads;
+ const next = threads.filter(
+ (thread) =>
+ !hidden.has(threadKey({ environmentId: thread.environmentId, threadId: thread.id })),
+ );
+ if (arrayElementsEqual(previousVisibleThreadShells, next)) return previousVisibleThreadShells;
+ previousVisibleThreadShells = next;
+ return next;
+ }).pipe(Atom.withLabel("environment-visible-thread-shell-list"));
+
// Lists that arrange threads read this, so a running thread's streaming
// ticks reach only what reads its live shell.
let previousQuietThreadShells: ReadonlyArray = [];
const quietThreadShellsAtom = Atom.make((get) => {
const next: EnvironmentThreadShell[] = [];
+ const hidden = get(hiddenThreadKeysAtom);
for (const ref of get(threadRefsAtom)) {
+ if (hidden.has(threadKey(ref))) continue;
const shell = get(quietThreadShellAtomFamily(threadKey(ref)));
if (shell !== null) next.push(shell);
}
@@ -635,12 +695,14 @@ export function createEnvironmentThreadShellAtoms(input: {
const navigationThreadShellsAtom = Atom.make((get) => {
const next: EnvironmentThreadShell[] = [];
const underParent = get(delegatedUnderParentAtom);
+ const hidden = get(hiddenThreadKeysAtom);
for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
const subagentRequests = get(pendingSubagentRequestsAtom(environmentId));
for (const thread of get(environmentThreadsAtom(environmentId))) {
if (
thread.archivedAt !== null ||
- isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent)
+ isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) ||
+ hidden.has(threadKey({ environmentId, threadId: thread.id }))
) {
continue;
}
@@ -659,6 +721,9 @@ export function createEnvironmentThreadShellAtoms(input: {
environmentThreadRefsByProjectAtom,
threadRefsAtom,
threadShellsAtom,
+ visibleThreadShellsAtom,
+ hiddenThreadKeysAtom,
+ anyEnvironmentHidingAtom,
quietThreadShellsAtom,
navigationThreadShellsAtom,
delegatedUnderParentAtom,
diff --git a/packages/client-runtime/src/state/threadTagFilter.test.ts b/packages/client-runtime/src/state/threadTagFilter.test.ts
new file mode 100644
index 0000000000000..d38e5386d5243
--- /dev/null
+++ b/packages/client-runtime/src/state/threadTagFilter.test.ts
@@ -0,0 +1,127 @@
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { threadKey } from "./entities.ts";
+import { hiddenThreadKeys, NO_HIDDEN_THREADS, visibleLinkedThreads } from "./threadTagFilter.ts";
+
+const local = EnvironmentId.make("local");
+const linked = EnvironmentId.make("linked");
+
+const thread = (
+ id: string,
+ tags: Array,
+ parent?: { readonly id: string; readonly relationship: "fork" | "subagent" },
+ delegatedFrom?: { readonly environmentId: EnvironmentId; readonly id: string },
+) => ({
+ id: ThreadId.make(id),
+ tags,
+ lineage: {
+ parentThreadId: parent === undefined ? null : ThreadId.make(parent.id),
+ relationshipToParent: parent?.relationship ?? null,
+ rootThreadId: ThreadId.make(parent?.id ?? id),
+ },
+ ...(delegatedFrom === undefined
+ ? {}
+ : {
+ delegatedFrom: {
+ environmentId: delegatedFrom.environmentId,
+ threadId: ThreadId.make(delegatedFrom.id),
+ title: "parent",
+ },
+ }),
+});
+
+const localThreads = [
+ thread("private", ["private"]),
+ thread("private-subagent", [], { id: "private", relationship: "subagent" }),
+ thread("private-fork", [], { id: "private", relationship: "fork" }),
+ thread("plain", []),
+ thread("plain-private-side-chat", ["private"], { id: "plain", relationship: "subagent" }),
+];
+const linkedThreads = [
+ thread("delegated-from-private", [], undefined, { environmentId: local, id: "private" }),
+ thread("linked-client", ["client"]),
+];
+const hiddenIds = (localTags: Array | null, linkedTags: Array | null) =>
+ [
+ ...hiddenThreadKeys([
+ { environmentId: local, threads: localThreads, hiddenTags: localTags },
+ { environmentId: linked, threads: linkedThreads, hiddenTags: linkedTags },
+ ]),
+ ].toSorted();
+const key = (environmentId: EnvironmentId, id: string) =>
+ threadKey({ environmentId, threadId: ThreadId.make(id) });
+
+describe("hiddenThreadKeys", () => {
+ it("hides nothing while every filter is off", () => {
+ expect(hiddenIds(null, null)).toEqual([]);
+ expect(
+ hiddenThreadKeys([{ environmentId: local, threads: localThreads, hiddenTags: null }]),
+ ).toBe(NO_HIDDEN_THREADS);
+ });
+
+ it("hides a tagged thread with its subagents and linked tasks, but not its forks", () => {
+ expect(hiddenIds(["private"], null)).toEqual(
+ [
+ key(local, "private"),
+ key(local, "private-subagent"),
+ key(linked, "delegated-from-private"),
+ ].toSorted(),
+ );
+ });
+
+ it("judges an archive page against live hidden parents", () => {
+ const archived = hiddenThreadKeys(
+ [{ environmentId: linked, threads: [linkedThreads[0]!], hiddenTags: null }],
+ new Set([key(local, "private")]),
+ );
+ expect([...archived]).toEqual([key(linked, "delegated-from-private")]);
+ });
+
+ it("hides a child whose parent is not loaded while any environment hides tags", () => {
+ const orphan = thread("orphan-task", [], undefined, { environmentId: local, id: "archived" });
+ const keys = (localTags: Array | null, linkedTags: Array | null) => [
+ ...hiddenThreadKeys([
+ { environmentId: local, threads: [], hiddenTags: localTags },
+ { environmentId: linked, threads: [orphan], hiddenTags: linkedTags },
+ ]),
+ ];
+ expect(keys(["private"], null)).toEqual([key(linked, "orphan-task")]);
+ expect(keys(null, ["client"])).toEqual([key(linked, "orphan-task")]);
+ expect(keys(null, null)).toEqual([]);
+ });
+
+ it("applies each environment's own tags", () => {
+ expect(hiddenIds(null, ["client", "private"])).toEqual([key(linked, "linked-client")]);
+ });
+
+ it("judges loaded PR rows by visibility and leaves out unloaded rows while hiding", () => {
+ const hidden = hiddenThreadKeys([
+ { environmentId: local, threads: localThreads, hiddenTags: ["private"] },
+ ]);
+ const rows = [
+ { id: ThreadId.make("private-subagent") },
+ { id: ThreadId.make("plain-private-side-chat") },
+ { id: ThreadId.make("off-shell") },
+ ];
+ const loaded = localThreads.map((entry) => ({ environmentId: local, threadId: entry.id }));
+ const visible = (hiding: boolean) =>
+ visibleLinkedThreads(rows, local, loaded, hidden, hiding).map((row) => row.id);
+ expect(visible(true)).toEqual(["plain-private-side-chat"]);
+ // Another machine hides tags while nothing here is hidden: unloaded rows still stay out.
+ expect(
+ visibleLinkedThreads(rows, local, loaded, NO_HIDDEN_THREADS, true).map((row) => row.id),
+ ).toEqual(["private-subagent", "plain-private-side-chat"]);
+ expect(visibleLinkedThreads(rows, local, loaded, NO_HIDDEN_THREADS, false)).toBe(rows);
+ });
+
+ it("lets a live hidden parent win over the same parent being loaded elsewhere", () => {
+ const parentKey = key(local, "private");
+ const archived = hiddenThreadKeys(
+ [{ environmentId: linked, threads: [linkedThreads[0]!], hiddenTags: null }],
+ new Set([parentKey]),
+ new Set([parentKey]),
+ );
+ expect([...archived]).toEqual([key(linked, "delegated-from-private")]);
+ });
+});
diff --git a/packages/client-runtime/src/state/threadTagFilter.ts b/packages/client-runtime/src/state/threadTagFilter.ts
new file mode 100644
index 0000000000000..78e314445bfb5
--- /dev/null
+++ b/packages/client-runtime/src/state/threadTagFilter.ts
@@ -0,0 +1,125 @@
+import {
+ type EnvironmentId,
+ isListedThreadLineage,
+ type OrchestrationV2ThreadShell,
+ type ScopedThreadRef,
+ type ThreadId,
+} from "@t3tools/contracts";
+
+import { threadKey } from "./entities.ts";
+
+type FilterableThread = Pick<
+ OrchestrationV2ThreadShell,
+ "id" | "lineage" | "tags" | "delegatedFrom"
+>;
+
+export interface TagFilteredEnvironment {
+ readonly environmentId: EnvironmentId;
+ readonly threads: ReadonlyArray;
+ /** Tags this environment hides, or null while its filter is off. */
+ readonly hiddenTags: ReadonlyArray | null;
+}
+
+export const NO_HIDDEN_THREADS: ReadonlySet = new Set();
+
+/** The thread a subagent, side chat or delegated task takes its visibility from, if any. */
+export function visibilityParentKey(
+ environmentId: EnvironmentId,
+ thread: Pick,
+): string | null {
+ const parentId = thread.lineage.parentThreadId;
+ if (!isListedThreadLineage(thread.lineage) && parentId !== null) {
+ return threadKey({ environmentId, threadId: parentId });
+ }
+ return thread.delegatedFrom === undefined ? null : threadKey(thread.delegatedFrom);
+}
+
+/**
+ * Pull request linked-thread rows the tag filter leaves visible. Loaded threads use the
+ * parent-aware hidden set. Rows the shell leaves out carry no lineage, so while any
+ * environment hides tags they stay out.
+ */
+export function visibleLinkedThreads(
+ rows: ReadonlyArray,
+ environmentId: EnvironmentId,
+ loadedThreadRefs: ReadonlyArray,
+ hiddenKeys: ReadonlySet,
+ hidingAnywhere: boolean,
+): ReadonlyArray {
+ if (!hidingAnywhere && hiddenKeys.size === 0) return rows;
+ const loaded = new Set(loadedThreadRefs.map((ref) => ref.threadId));
+ return rows.filter((row) =>
+ loaded.has(row.id)
+ ? !hiddenKeys.has(threadKey({ environmentId, threadId: row.id }))
+ : !hidingAnywhere,
+ );
+}
+
+function hasHiddenTag(
+ tags: ReadonlyArray | undefined,
+ hiddenTags: ReadonlyArray | null,
+): boolean {
+ return hiddenTags?.some((tag) => tags?.includes(tag) === true) === true;
+}
+
+/**
+ * Keys of threads the tag filter hides. Children (subagents, side chats, delegated tasks) follow
+ * their parent; while any environment hides tags, a child whose parent is not loaded stays hidden.
+ * For a partial list such as an archive page, `alreadyHidden` and `loadedElsewhere` give the
+ * hidden and loaded keys of live threads, and `hidingAnywhere` the global flag.
+ */
+export function hiddenThreadKeys(
+ environments: ReadonlyArray,
+ alreadyHidden: ReadonlySet = NO_HIDDEN_THREADS,
+ loadedElsewhere: ReadonlySet = NO_HIDDEN_THREADS,
+ hidingAnywhere: boolean = environments.some((environment) => environment.hiddenTags !== null),
+): ReadonlySet {
+ if (alreadyHidden.size === 0 && !hidingAnywhere) return NO_HIDDEN_THREADS;
+ const byKey = new Map<
+ string,
+ { thread: FilterableThread; environment: TagFilteredEnvironment }
+ >();
+ for (const environment of environments) {
+ for (const thread of environment.threads) {
+ byKey.set(threadKey({ environmentId: environment.environmentId, threadId: thread.id }), {
+ thread,
+ environment,
+ });
+ }
+ }
+ const hiddenByKey = new Map();
+ const isHidden = (key: string): boolean => {
+ if (alreadyHidden.has(key)) return true;
+ const known = hiddenByKey.get(key);
+ if (known !== undefined) return known;
+ const entry = byKey.get(key);
+ if (entry === undefined) return false;
+ const { thread, environment } = entry;
+ const parentKey = visibilityParentKey(environment.environmentId, thread);
+ const followsParent =
+ parentKey !== null && (byKey.has(parentKey) || alreadyHidden.has(parentKey));
+ if (parentKey !== null && !followsParent && loadedElsewhere.has(parentKey)) {
+ hiddenByKey.set(key, false);
+ return false;
+ }
+ if (parentKey !== null && !followsParent && hidingAnywhere) {
+ hiddenByKey.set(key, true);
+ return true;
+ }
+ if (!followsParent) {
+ const hidden = hasHiddenTag(thread.tags, environment.hiddenTags);
+ hiddenByKey.set(key, hidden);
+ return hidden;
+ }
+ // Seed before following the parent so a malformed cycle ends visible.
+ hiddenByKey.set(key, false);
+ const hidden = isHidden(parentKey);
+ hiddenByKey.set(key, hidden);
+ return hidden;
+ };
+ const hidden = new Set();
+ for (const key of byKey.keys()) {
+ if (isHidden(key)) hidden.add(key);
+ }
+ return hidden.size === 0 ? NO_HIDDEN_THREADS : hidden;
+}
diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts
index 07b91c36fe539..60aba48bc02f0 100644
--- a/packages/client-runtime/src/state/threads.ts
+++ b/packages/client-runtime/src/state/threads.ts
@@ -1130,3 +1130,4 @@ export * from "./threadFamilies.ts";
export * from "./olderSettledThreads.ts";
export * from "./threadShell.ts";
export * from "./threadState.ts";
+export * from "./threadTagFilter.ts";
diff --git a/packages/client-runtime/src/textPaste.test.ts b/packages/client-runtime/src/textPaste.test.ts
new file mode 100644
index 0000000000000..650c3ab528957
--- /dev/null
+++ b/packages/client-runtime/src/textPaste.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { isPasteAsTextShortcut } from "./textPaste.ts";
+
+describe("isPasteAsTextShortcut", () => {
+ it("accepts the V key on non-Latin layouts but follows the letter on Latin ones", () => {
+ const chord = { metaKey: false, ctrlKey: true, shiftKey: true, altKey: false };
+ expect(isPasteAsTextShortcut({ ...chord, key: "М", code: "KeyV" }, false)).toBe(true);
+ expect(isPasteAsTextShortcut({ ...chord, key: "k", code: "KeyV" }, false)).toBe(false);
+ expect(isPasteAsTextShortcut({ ...chord, key: "V", code: "Period" }, false)).toBe(true);
+ });
+});
diff --git a/packages/client-runtime/src/textPaste.ts b/packages/client-runtime/src/textPaste.ts
index 422018d92404f..59a6182034572 100644
--- a/packages/client-runtime/src/textPaste.ts
+++ b/packages/client-runtime/src/textPaste.ts
@@ -4,12 +4,20 @@ const textEncoder = new TextEncoder();
export type PastedTextDisposition = "attachment" | "inline";
+/** Non-Latin layouts (Cyrillic, Greek) report the typed letter; fall back to the physical key. */
+function isVKey(event: { readonly key: string; readonly code?: string }): boolean {
+ const key = event.key.toLowerCase();
+ return /^[a-z]$/.test(key) ? key === "v" : event.code === "KeyV";
+}
+
export function isPasteAsTextShortcut(
- event: Pick,
+ event: Pick & {
+ readonly code?: string;
+ },
macPlatform: boolean,
): boolean {
return (
- event.key.toLowerCase() === "v" &&
+ isVKey(event) &&
event.shiftKey &&
!event.altKey &&
(macPlatform ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 8f71a6f7d489e..01448bb05081f 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -38,6 +38,7 @@ export * from "./peerLink.ts";
export * from "./threadMetadataMcp.ts";
export * from "./threadPullRequest.ts";
export * from "./threadSearch.ts";
+export * from "./threadTags.ts";
export * from "./threadTitle.ts";
export * from "./t3ProjectFile.ts";
export * from "./project.ts";
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 645cc9631cd6b..2767fd83cbc34 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -44,6 +44,7 @@ import {
import { AuthMcpClientAccess } from "./auth.ts";
import { ChatAttachment } from "./chatAttachment.ts";
import { ThreadGuestShare } from "./guestShare.ts";
+import { StoredThreadTags, ThreadTag } from "./threadTags.ts";
import {
OrchestrationSearchThreadsInput,
OrchestrationSearchThreadsResult,
@@ -487,6 +488,8 @@ export const OrchestrationV2AppThread = Schema.Struct({
autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ /** Absent on threads written before tags existed; read as empty. */
+ tags: Schema.optional(StoredThreadTags),
lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe(
Schema.withDecodingDefault(Effect.succeed(null)),
),
@@ -1490,6 +1493,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([
export type OrchestrationV2UserMessageInputIntent =
typeof OrchestrationV2UserMessageInputIntent.Type;
+/** A same-harness model or effort change that took effect with this message. */
+export const OrchestrationV2ModelSwitch = Schema.Struct({
+ from: ModelSelection,
+ to: ModelSelection,
+});
+export type OrchestrationV2ModelSwitch = typeof OrchestrationV2ModelSwitch.Type;
+
const OrchestrationV2TurnItemBaseFields = {
/** Wire-only cursor for collapsed work; persisted items keep their complete history. */
deferredWork: Schema.optionalKey(
@@ -1611,6 +1621,7 @@ export const OrchestrationV2TurnItem = Schema.Union([
textOmitted: DedupedBodyMarker,
context: Schema.optional(OrchestrationMessageContext),
attachments: Schema.Array(ChatAttachment),
+ modelSwitch: Schema.optional(OrchestrationV2ModelSwitch),
}),
Schema.Struct({
...OrchestrationV2TurnItemBaseFields,
@@ -1879,6 +1890,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([
"thread.unpinned",
"thread.pin-reordered",
"thread.active-reordered",
+ "thread.tags-updated",
"thread.visited",
"thread.marked-unread",
"thread.metadata-updated",
@@ -2131,6 +2143,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ /** Absent on threads written before tags existed; read as empty. */
+ tags: Schema.optional(StoredThreadTags),
lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
opens: Schema.optional(LenientThreadOpens),
titleRegeneration: Schema.optional(
@@ -2441,6 +2455,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
text: Schema.String,
context: Schema.optional(OrchestrationMessageContext),
attachments: Schema.Array(ChatAttachment),
+ modelSwitch: Schema.optional(OrchestrationV2ModelSwitch),
}),
Schema.Struct({
...OrchestrationV2TurnItemJsonBaseFields,
@@ -2759,6 +2774,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([
"thread.unpinned",
"thread.pin-reordered",
"thread.active-reordered",
+ "thread.tags-updated",
"thread.visited",
"thread.marked-unread",
"thread.pull-request-synced",
@@ -3000,6 +3016,13 @@ export const OrchestrationV2Command = Schema.Union([
commandId: CommandId,
threadId: ThreadId,
}),
+ Schema.Struct({
+ type: Schema.Literal("thread.tag.set"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ tag: ThreadTag,
+ tagged: Schema.Boolean,
+ }),
Schema.Struct({
type: Schema.Literal("thread.pin.reorder"),
commandId: CommandId,
@@ -3569,6 +3592,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {
getOlderSettledThreads: "orchestration.getOlderSettledThreads",
getThreadProjection: "orchestration.getThreadProjection",
getWorkflowScript: "orchestration.getWorkflowScript",
+ getNativeChildWork: "orchestration.getNativeChildWork",
stopWorkflow: "orchestration.stopWorkflow",
forceStopThread: "orchestration.forceStopThread",
getTurnItem: "orchestration.getTurnItem",
@@ -4008,6 +4032,53 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({
export type OrchestrationV2GetWorkflowScriptResult =
typeof OrchestrationV2GetWorkflowScriptResult.Type;
+/** Names a provider-native subagent's child thread. */
+export const OrchestrationV2GetNativeChildWorkInput = Schema.Struct({
+ threadId: ThreadId,
+});
+export type OrchestrationV2GetNativeChildWorkInput =
+ typeof OrchestrationV2GetNativeChildWorkInput.Type;
+
+export const OrchestrationV2NativeChildStep = Schema.Struct({
+ index: NonNegativeInt,
+ type: Schema.String,
+ status: Schema.NullOr(Schema.String),
+ text: Schema.NullOr(Schema.String),
+ toolCalls: Schema.Array(Schema.Struct({ name: Schema.String, input: Schema.String })),
+});
+export type OrchestrationV2NativeChildStep = typeof OrchestrationV2NativeChildStep.Type;
+
+/** Read-only: native children cannot take messages. */
+export const OrchestrationV2NativeChildWork = Schema.Struct({
+ conversationId: TrimmedNonEmptyString,
+ role: Schema.String,
+ prompt: Schema.String,
+ /** Null when the provider has no readable transcript for this child. */
+ steps: Schema.NullOr(Schema.Array(OrchestrationV2NativeChildStep)),
+ /** Earlier steps were left out to keep the read bounded. */
+ truncated: Schema.Boolean,
+});
+export type OrchestrationV2NativeChildWork = typeof OrchestrationV2NativeChildWork.Type;
+
+/** Empty when the provider's records do not prove which children the subagent launched. */
+export const OrchestrationV2GetNativeChildWorkResult = Schema.Struct({
+ children: Schema.Array(OrchestrationV2NativeChildWork),
+});
+export type OrchestrationV2GetNativeChildWorkResult =
+ typeof OrchestrationV2GetNativeChildWorkResult.Type;
+
+export class OrchestrationV2GetNativeChildWorkError extends Schema.TaggedError()(
+ "OrchestrationV2GetNativeChildWorkError",
+ {
+ threadId: ThreadId,
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ return "Failed to read the subagent's work.";
+ }
+}
+
const WORKFLOW_FILE_ERROR_MESSAGES = {
"invalid-path": "Workflow files must be absolute paths with the expected extension.",
"root-unavailable": "Workflow file root unavailable.",
@@ -4187,6 +4258,10 @@ export const OrchestrationV2RpcSchemas = {
input: OrchestrationV2GetWorkflowScriptInput,
output: OrchestrationV2GetWorkflowScriptResult,
},
+ getNativeChildWork: {
+ input: OrchestrationV2GetNativeChildWorkInput,
+ output: OrchestrationV2GetNativeChildWorkResult,
+ },
getTurnItem: {
input: OrchestrationV2GetTurnItemInput,
output: OrchestrationV2GetTurnItemResult,
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index 4740a9fd0ccf7..4769bfd7955c7 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -60,6 +60,14 @@ const TurnTokenUsageCommonFields = {
cacheCreationTokens: Schema.optional(NonNegativeInt),
reasoningTokens: Schema.optional(NonNegativeInt),
hasSubagents: Schema.Boolean,
+ // Native model ids the provider reported for the main agent's work. Absent means unknown.
+ models: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)),
+ // The provider ran a local command; its counters describe that result, not model work.
+ localCommand: Schema.optional(Schema.Boolean),
+ // Provider-ledger counts beyond the main agent, such as native subagents and compaction.
+ otherUsage: Schema.optional(
+ Schema.Struct({ inputTokens: NonNegativeInt, outputTokens: NonNegativeInt }),
+ ),
};
export const TurnTokenUsage = Schema.Union([
Schema.Struct({
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index a48a4325de7a0..27eb258bd7792 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -184,6 +184,7 @@ import {
import {
ORCHESTRATION_V2_WS_METHODS,
OrchestrationGetWorkflowScriptError,
+ OrchestrationV2GetNativeChildWorkError,
OrchestrationV2StopWorkflowError,
OrchestrationV2ForceStopThreadError,
OrchestrationV2DispatchCommandError,
@@ -1674,6 +1675,15 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(
},
);
+const WsOrchestrationV2GetNativeChildWorkRpc = Rpc.make(
+ ORCHESTRATION_V2_WS_METHODS.getNativeChildWork,
+ {
+ payload: OrchestrationV2RpcSchemas.getNativeChildWork.input,
+ success: OrchestrationV2RpcSchemas.getNativeChildWork.output,
+ error: Schema.Union([OrchestrationV2GetNativeChildWorkError, EnvironmentAuthorizationError]),
+ },
+);
+
const WsOrchestrationV2GetTurnItemRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnItem, {
payload: OrchestrationV2RpcSchemas.getTurnItem.input,
success: OrchestrationV2RpcSchemas.getTurnItem.output,
@@ -1987,6 +1997,7 @@ export const WsRpcGroup = RpcGroup.make(
WsSubscribeAuthAccessRpc,
WsOrchestrationV2DispatchCommandRpc,
WsOrchestrationV2GetWorkflowScriptRpc,
+ WsOrchestrationV2GetNativeChildWorkRpc,
WsOrchestrationV2StopWorkflowRpc,
WsOrchestrationV2ForceStopThreadRpc,
WsOrchestrationV2GetTurnItemRpc,
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 859644a54c72f..4ff7d2c9618f2 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -126,6 +126,26 @@ describe("ServerSettings usage price overrides", () => {
expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
}
});
+
+ it("ignores an invalid model filter without dropping the other settings", () => {
+ const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] };
+ expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid);
+ const settings = decodeServerSettings({
+ usageModelFilter: { exclude: [{ model: "" }] },
+ usageModelAliases: { "drift-9": "orbit-1" },
+ });
+ expect(settings.usageModelFilter).toBeUndefined();
+ expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" });
+ });
+});
+
+it("drops a hand-edited non-string hidden tag and keeps the rest of the settings", () => {
+ const settings = decodeServerSettings({
+ hiddenThreadTags: ["private", 123],
+ hideTaggedThreads: true,
+ });
+ expect(settings.hiddenThreadTags).toEqual(["private"]);
+ expect(settings.hideTaggedThreads).toBe(true);
});
describe("ServerSettings secret providers", () => {
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 1e611ee758b9b..2f11602d085ab 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1193,6 +1193,17 @@ export const StorageCleanupSettings = Schema.Struct({
});
export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
+// Coder: contracts' `usage.ts` (the usage dashboard) is not carried, so the settings field's
+// filter schema (#28952) is declared here as upstream declares it there.
+const UsageModelSelector = Schema.Struct({
+ provider: Schema.optional(TrimmedNonEmptyString),
+ model: TrimmedNonEmptyString,
+});
+const UsageModelFilter = Schema.Struct({
+ include: Schema.optional(Schema.Array(UsageModelSelector)),
+ exclude: Schema.optional(Schema.Array(UsageModelSelector)),
+});
+
export const ServerSettings = Schema.Struct({
// Null means unmigrated; empty is authoritative and must not resurrect cleared preferences.
favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
@@ -1269,6 +1280,18 @@ export const ServerSettings = Schema.Struct({
snoozePresets: SnoozePresetSettings.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_PRESETS)),
),
+ /** Clients hide threads with these tags while `hideTaggedThreads` is on. Bad entries drop alone, so a hand edit cannot reset settings. */
+ hiddenThreadTags: Schema.Array(Schema.Unknown).pipe(
+ Schema.decodeTo(
+ Schema.Array(Schema.String),
+ SchemaTransformation.transform, ReadonlyArray>({
+ decode: (values) => values.filter((value) => typeof value === "string"),
+ encode: (values) => values,
+ }),
+ ),
+ Schema.withDecodingDefault(Effect.succeed([])),
+ ),
+ hideTaggedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** Beta: guest links for this machine's threads. Off refuses new links and pauses existing ones. */
guestSharingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/**
@@ -1376,6 +1399,9 @@ export const ServerSettings = Schema.Struct({
usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe(
Schema.withDecodingDefault(Effect.succeed({})),
),
+
+ /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */
+ usageModelFilter: ForwardCompatibleOptional(UsageModelFilter),
});
export type ServerSettings = typeof ServerSettings.Type;
@@ -1631,6 +1657,8 @@ export const ServerSettingsPatch = Schema.Struct({
autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),
snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean),
snoozePresets: Schema.optionalKey(SnoozePresetSettings),
+ hiddenThreadTags: Schema.optionalKey(Schema.Array(Schema.String)),
+ hideTaggedThreads: Schema.optionalKey(Schema.Boolean),
guestSharingEnabled: Schema.optionalKey(Schema.Boolean),
linkedEnvironmentsEnabled: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
diff --git a/packages/contracts/src/threadTags.ts b/packages/contracts/src/threadTags.ts
new file mode 100644
index 0000000000000..ec6d8285c1aed
--- /dev/null
+++ b/packages/contracts/src/threadTags.ts
@@ -0,0 +1,18 @@
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import { TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+/** Tags match by exact spelling and case after trimming. */
+export const ThreadTag = TrimmedNonEmptyString.check(
+ Schema.isMaxLength(64),
+ Schema.makeFilter((value) =>
+ /[\p{Cc}]/u.test(value) ? "A thread tag must be a single line of text." : true,
+ ),
+);
+export type ThreadTag = typeof ThreadTag.Type;
+
+/** Tags read back from storage or a peer. Lenient so a later tag rule never fails a whole thread. */
+export const StoredThreadTags = Schema.Array(Schema.String).pipe(
+ Schema.catchDecoding(() => Effect.succeedSome([])),
+);
diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts
index 1d5b78849f08a..8a1cc36390bbd 100644
--- a/packages/effect-acp/src/client.test.ts
+++ b/packages/effect-acp/src/client.test.ts
@@ -1042,6 +1042,100 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
}),
);
+ it.effect.each([
+ { shape: "v1", deleteCapability: {} },
+ { shape: "v2", deleteCapability: {} },
+ { shape: "v2", deleteCapability: undefined },
+ { shape: "v2", deleteCapability: null },
+ ])(
+ "gates session/delete for $shape with delete=$deleteCapability",
+ ({ shape, deleteCapability }) =>
+ Effect.gen(function* () {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ const scope = yield* Scope.make();
+ const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
+ const deleteSession = acp.agent.deleteSession({ sessionId: "kiro-session-1" });
+
+ const initializeFiber = yield* acp.agent
+ .initialize({
+ protocolVersion: 1,
+ clientCapabilities: {
+ fs: { readTextFile: false, writeTextFile: false },
+ terminal: false,
+ },
+ clientInfo: { name: "effect-acp-test", version: "0.0.0" },
+ })
+ .pipe(Effect.forkScoped);
+ const initializeRequest = yield* Queue.take(output).pipe(
+ Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(InitializeRequestV1))),
+ );
+ yield* Queue.offer(
+ input,
+ shape === "v1"
+ ? yield* encodeJsonl(InitializeResponseV1, {
+ jsonrpc: "2.0",
+ id: initializeRequest.id,
+ result: {
+ protocolVersion: 1,
+ agentInfo: { name: "kiro-acp", version: "0.0.0" },
+ agentCapabilities: {
+ sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} },
+ },
+ },
+ })
+ : yield* encodeJsonl(InitializeResponse, {
+ jsonrpc: "2.0",
+ id: initializeRequest.id,
+ result: {
+ protocolVersion: 2,
+ info: { name: "v2-agent", version: "0.0.0" },
+ capabilities: {
+ session: deleteCapability === undefined ? {} : { delete: deleteCapability },
+ },
+ },
+ }),
+ );
+ yield* Fiber.join(initializeFiber);
+
+ const deleteFailed = yield* Deferred.make();
+ const deleteFiber = yield* deleteSession.pipe(
+ Effect.tapError((error) => Deferred.succeed(deleteFailed, error)),
+ Effect.forkScoped,
+ );
+ const outcome = yield* Effect.raceFirst(
+ Queue.take(output).pipe(Effect.map((line) => ({ line }))),
+ Deferred.await(deleteFailed).pipe(Effect.map((error) => ({ error }))),
+ );
+ if ("error" in outcome) {
+ if (deleteCapability != null) {
+ return yield* outcome.error;
+ }
+ assert.equal(outcome.error._tag, "AcpRequestError");
+ assert.equal(
+ outcome.error._tag === "AcpRequestError" ? outcome.error.code : undefined,
+ -32601,
+ );
+ yield* Scope.close(scope, Exit.void);
+ return;
+ }
+ assert.exists(deleteCapability, "session/delete reached transport without its capability");
+ const deleteRequest = yield* Schema.decodeEffect(
+ Schema.fromJsonString(jsonRpcRequest("session/delete", AcpSchemaV1.DeleteSessionRequest)),
+ )(outcome.line);
+ assert.equal(deleteRequest.params.sessionId, "kiro-session-1");
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), {
+ jsonrpc: "2.0",
+ id: deleteRequest.id,
+ result: {},
+ }),
+ );
+ assert.deepEqual(yield* Fiber.join(deleteFiber), {});
+ yield* Scope.close(scope, Exit.void);
+ }),
+ );
+
it.effect("preserves exact ids for parallel requests with identical payloads", () =>
Effect.gen(function* () {
const { stdio, input, output } = yield* makeInMemoryStdio();
diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts
index c8ac3d9b841ea..ca1dba902ee8d 100644
--- a/packages/effect-acp/src/client.ts
+++ b/packages/effect-acp/src/client.ts
@@ -782,6 +782,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
| ((method: string, params: unknown) => Effect.Effect)
| undefined;
let negotiatedProtocolGeneration: 1 | 2 | undefined;
+ let sessionDeleteSupported = false;
const runNotificationHandlers = (
registration: BufferedNotificationHandler ,
@@ -1081,12 +1082,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
// ACP v1 protocolVersion values were not generation identifiers in
// every shipped agent. Antigravity, for example, reports version 2
// with the v1 agentInfo/agentCapabilities response shape.
- if (isV2InitializeResponse(response)) {
- negotiatedProtocolGeneration = 2;
- return normalizeInitializeResponse(response);
- }
- negotiatedProtocolGeneration = 1;
- return response as AcpSchema.InitializeResponse;
+ const isV2 = isV2InitializeResponse(response);
+ negotiatedProtocolGeneration = isV2 ? 2 : 1;
+ const normalized = isV2
+ ? normalizeInitializeResponse(response)
+ : (response as AcpSchema.InitializeResponse);
+ sessionDeleteSupported =
+ normalized.agentCapabilities?.sessionCapabilities?.delete != null;
+ return normalized;
}),
),
authenticate: (payload) =>
@@ -1182,9 +1185,11 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
Effect.map((response) => response as AcpSchema.CloseSessionResponse),
),
deleteSession: (payload) =>
- negotiatedProtocolGeneration === 1
- ? Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.session_delete))
- : callRpc(AGENT_METHODS.session_delete, rpc[AGENT_METHODS.session_delete](payload)),
+ Effect.suspend(() =>
+ sessionDeleteSupported
+ ? callRpc(AGENT_METHODS.session_delete, rpc[AGENT_METHODS.session_delete](payload))
+ : Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.session_delete)),
+ ),
listProviders: (payload) =>
negotiatedProtocolGeneration === 1
? Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.providers_list))
diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts
index 7b2a923e7b025..eae3394a1653f 100644
--- a/packages/effect-acp/src/protocol.test.ts
+++ b/packages/effect-acp/src/protocol.test.ts
@@ -108,37 +108,39 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
},
});
- yield* Queue.offer(
- input,
- yield* encodeJsonl(SessionUpdateNotification, {
- jsonrpc: "2.0",
- method: "session/update",
- params: {
- sessionId: "session-1",
- update: {
- sessionUpdate: "plan",
- entries: [
- {
- content: "Inspect repository",
- priority: "high",
- status: "in_progress",
- },
- ],
- },
+ const updateFrame = yield* encodeJsonl(SessionUpdateNotification, {
+ jsonrpc: "2.0",
+ method: "session/update",
+ params: {
+ sessionId: "session-1",
+ update: {
+ sessionUpdate: "plan",
+ entries: [
+ {
+ content: "Inspect repository",
+ priority: "high",
+ status: "in_progress",
+ },
+ ],
},
- }),
- );
+ },
+ });
+ const completionFrame = yield* encodeJsonl(ElicitationCompleteNotification, {
+ jsonrpc: "2.0",
+ method: "elicitation/complete",
+ params: {
+ elicitationId: "elicitation-1",
+ },
+ });
- yield* Queue.offer(
- input,
- yield* encodeJsonl(ElicitationCompleteNotification, {
- jsonrpc: "2.0",
- method: "elicitation/complete",
- params: {
- elicitationId: "elicitation-1",
- },
- }),
- );
+ // Frames split across reads, with one read ending one frame and starting the next.
+ const wire = new Uint8Array([...updateFrame, ...completionFrame]);
+ const splits = [10, updateFrame.length + 10, wire.length];
+ let start = 0;
+ for (const end of splits) {
+ yield* Queue.offer(input, wire.subarray(start, end));
+ start = end;
+ }
const [update, completion] = yield* Deferred.await(notifications);
assert.equal(update?._tag, "SessionUpdate");
diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts
index feb544ba12a34..8763e66361e12 100644
--- a/packages/effect-acp/src/protocol.ts
+++ b/packages/effect-acp/src/protocol.ts
@@ -169,9 +169,15 @@ const makeStrictNdJsonRpcParser = () => {
let buffer = "";
return {
decode: (bytes: Uint8Array | string): ReadonlyArray => {
- buffer += typeof bytes === "string" ? bytes : decoder.decode(bytes, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
+ const chunk = typeof bytes === "string" ? bytes : decoder.decode(bytes, { stream: true });
+ const lastNewline = chunk.lastIndexOf("\n");
+ // Splitting the whole buffer per chunk is quadratic for multi-megabyte lines.
+ if (lastNewline === -1) {
+ buffer += chunk;
+ return [];
+ }
+ const lines = (buffer + chunk.slice(0, lastNewline)).split("\n");
+ buffer = chunk.slice(lastNewline + 1);
return lines.flatMap((line) => codec.decode(line));
},
encode: (response: Parameters[0]) => {
diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts
index 04a0660abad2c..141484ba721a1 100644
--- a/packages/effect-codex-app-server/src/protocol.test.ts
+++ b/packages/effect-codex-app-server/src/protocol.test.ts
@@ -7,6 +7,7 @@ import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
import * as Stdio from "effect/Stdio";
import * as Stream from "effect/Stream";
+import * as Tracer from "effect/Tracer";
import * as TestClock from "effect/testing/TestClock";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -186,6 +187,95 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => {
}),
);
+ it.effect(
+ "correlates concurrent request spans through responses, failures and interruption",
+ () => {
+ const spans: Array = [];
+ const tracer = Tracer.make({
+ span: (options) => {
+ const span = new Tracer.NativeSpan(options);
+ spans.push(span);
+ return span;
+ },
+ });
+ return Effect.gen(function* () {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio });
+ const methods = ["model/list", "account/read", "skills/list"];
+ const requests = [];
+ for (const method of methods) {
+ requests.push(
+ yield* transport.request(method, { secret: "private-payload" }).pipe(Effect.forkScoped),
+ );
+ yield* Queue.take(output);
+ }
+ yield* Queue.offer(
+ input,
+ encodeJsonl({ id: 2, error: { code: -32602, message: "Invalid params" } }),
+ );
+ yield* Fiber.await(requests[1]!);
+ yield* Queue.offer(input, encodeJsonl({ id: 1, result: { secret: "private-result" } }));
+ yield* Fiber.join(requests[0]!);
+ yield* Fiber.interrupt(requests[2]!);
+ // A late response for an interrupted request must not affect the next request.
+ yield* Queue.offer(input, encodeJsonl({ id: 3, result: {} }));
+ const next = yield* transport.request("config/read").pipe(Effect.forkScoped);
+ yield* Queue.take(output);
+ yield* Queue.offer(input, encodeJsonl({ id: 4, result: {} }));
+ yield* Fiber.join(next);
+ for (const [index, method] of [...methods, "config/read"].entries()) {
+ const request = spans.find(
+ (span) =>
+ span.name === "CodexAppServerProtocol.request" &&
+ span.attributes.get("rpc.request_id") === index + 1,
+ );
+ assert.isDefined(request);
+ assert.equal(request!.attributes.get("rpc.method"), method);
+ assert.equal(request!.attributes.get("rpc.system"), "jsonrpc");
+ assert.equal(request!.status._tag, "Ended");
+ if (request!.status._tag === "Ended") {
+ assert.equal(
+ request!.status.exit._tag,
+ index === 1 || index === 2 ? "Failure" : "Success",
+ );
+ }
+ for (const phase of ["enqueue", "awaitResponse"]) {
+ const child: Tracer.NativeSpan | undefined = spans.find(
+ (span) =>
+ span.name === `CodexAppServerProtocol.request.${phase}` &&
+ span.parent?._tag === "Some" &&
+ span.parent.value.spanId === request!.spanId,
+ );
+ assert.isDefined(child);
+ assert.equal(child!.traceId, request!.traceId);
+ assert.equal(child!.attributes.get("rpc.method"), method);
+ assert.equal(child!.attributes.get("rpc.request_id"), index + 1);
+ assert.equal(child!.status._tag, "Ended");
+ }
+ }
+ const failedSend = yield* transport
+ .request("thread/start", { value: 1n })
+ .pipe(Effect.exit);
+ assert.equal(failedSend._tag, "Failure");
+ const sendSpans = spans.filter((span) => span.attributes.get("rpc.request_id") === 5);
+ assert.deepEqual(
+ sendSpans.map((span) => span.name),
+ ["CodexAppServerProtocol.request", "CodexAppServerProtocol.request.enqueue"],
+ );
+ for (const span of sendSpans) {
+ assert.equal(span.attributes.get("rpc.method"), "thread/start");
+ assert.equal(span.status._tag, "Ended");
+ if (span.status._tag === "Ended") assert.equal(span.status.exit._tag, "Failure");
+ }
+ for (const span of spans) {
+ const attrs = JSON.stringify([...span.attributes]);
+ assert.notInclude(attrs, "private-payload");
+ assert.notInclude(attrs, "private-result");
+ }
+ }).pipe(Effect.withTracer(tracer));
+ },
+ );
+
it.effect.each([1, 7, 1024])(
"preserves JSONL framing and UTF-8 across %i-byte input chunks",
(chunkSize) =>
diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts
index f9f7fa5e30dce..16ea67e5efeaa 100644
--- a/packages/effect-codex-app-server/src/protocol.ts
+++ b/packages/effect-codex-app-server/src/protocol.ts
@@ -544,17 +544,30 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa
nextRequestId,
(current) => [current, current + 1] as const,
);
+ const attributes = {
+ "rpc.system": "jsonrpc",
+ "rpc.method": method,
+ "rpc.request_id": requestId,
+ };
+ yield* Effect.annotateCurrentSpan(attributes);
const deferred = yield* Deferred.make();
- yield* Ref.update(pending, (current) =>
- new Map(current).set(String(requestId), { deferred, method }),
- );
- yield* offerOutgoing({
- id: requestId,
- method,
- ...(payload !== undefined ? { params: payload } : {}),
- }).pipe(Effect.tapError(() => removePending(String(requestId))));
- return yield* Deferred.await(deferred).pipe(
- Effect.onInterrupt(() => removePending(String(requestId))),
+ return yield* Effect.acquireUseRelease(
+ Ref.update(pending, (current) =>
+ new Map(current).set(String(requestId), { deferred, method }),
+ ),
+ () =>
+ Effect.gen(function* () {
+ yield* offerOutgoing({
+ id: requestId,
+ method,
+ ...(payload !== undefined ? { params: payload } : {}),
+ }).pipe(Effect.withSpan("CodexAppServerProtocol.request.enqueue", { attributes }));
+ // Includes queue/stdin delivery, Codex work and response routing, not just T3 CPU.
+ return yield* Deferred.await(deferred).pipe(
+ Effect.withSpan("CodexAppServerProtocol.request.awaitResponse", { attributes }),
+ );
+ }),
+ () => removePending(String(requestId)),
);
});
diff --git a/packages/effect-codex-app-server/src/replay.ts b/packages/effect-codex-app-server/src/replay.ts
index 11ef80c02f5bf..7aba60a1cfd79 100644
--- a/packages/effect-codex-app-server/src/replay.ts
+++ b/packages/effect-codex-app-server/src/replay.ts
@@ -237,9 +237,9 @@ function recorderThreadConfigKeys(transcript: CodexAppServerReplayTranscript): R
}
/**
- * `cwd`, `model` and host-supplied MCP servers (machine-local URLs, short-lived
- * authorization headers) vary per machine, and recorder-only keys are not the
- * client's.
+ * `cwd`, `model`, host-supplied MCP servers (machine-local URLs, short-lived
+ * authorization headers) and the T3 thread id vary per machine or run, and
+ * recorder-only keys are not the client's.
*/
function normalizeThreadRequest(frame: unknown, ignoredConfigKeys: ReadonlySet): unknown {
if (typeof frame !== "object" || frame === null) return frame;
@@ -257,7 +257,10 @@ function normalizeThreadRequest(frame: unknown, ignoredConfigKeys: ReadonlySet).filter(
- ([key]) => key !== "mcp_servers" && !key.startsWith("mcp_servers."),
+ ([key]) =>
+ key !== "mcp_servers" &&
+ !key.startsWith("mcp_servers.") &&
+ key !== "shell_environment_policy.set.T3CODE_THREAD_ID",
),
);
for (const key of ignoredConfigKeys) delete owned[key];
diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts
index 350494aec9004..0f7da7c836f14 100644
--- a/packages/provider-acp/src/server/AcpSessionRuntime.ts
+++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts
@@ -123,7 +123,12 @@ export interface AcpSessionRuntimeOptions {
/** Authenticate before opening: Antigravity selects its credential source from the method. */
readonly authenticateEagerly?: boolean;
readonly mcpServers?: ReadonlyArray;
+ /** Sent on every session/new, load, resume and fork; omitting them clears an agent's stored roots. */
readonly additionalDirectories?: ReadonlyArray;
+ /** Session `_meta`, chosen from the negotiated agent before each session setup or activation. */
+ readonly sessionMeta?: (
+ initializeResult: EffectAcpSchema.InitializeResponse,
+ ) => EffectAcpSchema.NewSessionRequest["_meta"] | undefined;
readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"];
readonly transformSessionUpdate?: (
notification: EffectAcpSchema.SessionNotification,
@@ -2103,6 +2108,16 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
return activationOptions?.mcpServers ?? options.mcpServers ?? [];
};
+ const sessionSetupFields = (initializeResult: EffectAcpSchema.InitializeResponse) => {
+ const meta = options.sessionMeta?.(initializeResult);
+ return {
+ ...(options.additionalDirectories?.length
+ ? { additionalDirectories: options.additionalDirectories }
+ : {}),
+ ...(meta == null ? {} : { _meta: meta }),
+ };
+ };
+
const startOnce = Effect.gen(function* () {
const initializeResult = yield* initialize;
@@ -2158,9 +2173,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
| EffectAcpSchema.LoadSessionResponse
| EffectAcpSchema.NewSessionResponse
| EffectAcpSchema.ResumeSessionResponse;
- const additionalDirectories = options.additionalDirectories?.length
- ? { additionalDirectories: options.additionalDirectories }
- : {};
+ const additionalDirectories = sessionSetupFields(initializeResult);
if (options.resumeSessionId) {
sessionId = options.resumeSessionId;
if (
@@ -2395,6 +2408,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
const requestPayload = {
sessionId,
cwd: options.cwd,
+ ...sessionSetupFields(started.initializeResult),
mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
} satisfies EffectAcpSchema.LoadSessionRequest;
return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult);
@@ -2407,6 +2421,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
const requestPayload = {
sessionId,
cwd: options.cwd,
+ ...sessionSetupFields(started.initializeResult),
mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
} satisfies EffectAcpSchema.ResumeSessionRequest;
return runLoggedRequest(
@@ -2423,6 +2438,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
const requestPayload = {
sessionId,
cwd: options.cwd,
+ ...sessionSetupFields(started.initializeResult),
mcpServers: sessionMcpServers(started.initializeResult, activationOptions),
} satisfies EffectAcpSchema.ForkSessionRequest;
return runLoggedRequest(
diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts
index 454881bf64e60..b40824edf4c37 100644
--- a/packages/provider-acp/src/server/adapter.ts
+++ b/packages/provider-acp/src/server/adapter.ts
@@ -232,28 +232,35 @@ export function acpPerTurnPromptUsage(
usage: EffectAcpSchema.Usage | null | undefined,
hasSubagents: boolean,
terminalStatus: OrchestrationV2ProviderTurn["status"],
+ geminiUsage = false,
): TurnTokenUsage {
if (usage == null) {
return { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents };
}
+ const inputTokens = geminiUsage
+ ? usage.inputTokens + (usage.cachedReadTokens ?? 0)
+ : usage.inputTokens;
+ const outputTokens = geminiUsage
+ ? usage.outputTokens + (usage.thoughtTokens ?? 0)
+ : usage.outputTokens;
// TurnTokenUsage contract: input includes cache read + write and reasoning
// is a subset of output, so clamp counters that exceed their parent and
// drop the ones the agent left unset. Cache writes are capped at whatever
// cache reads leave of input, so the pair cannot exceed it.
const clampSubset = (value: number | null | undefined, parent: number) =>
value == null ? undefined : Math.min(value, parent);
- const cachedInputTokens = clampSubset(usage.cachedReadTokens, usage.inputTokens);
+ const cachedInputTokens = clampSubset(usage.cachedReadTokens, inputTokens);
const cacheCreationTokens = clampSubset(
usage.cachedWriteTokens,
- usage.inputTokens - (cachedInputTokens ?? 0),
+ inputTokens - (cachedInputTokens ?? 0),
);
- const reasoningTokens = clampSubset(usage.thoughtTokens, usage.outputTokens);
+ const reasoningTokens = clampSubset(usage.thoughtTokens, outputTokens);
return {
usageScope: "main_agent",
usageStatus: terminalStatus === "completed" ? "complete" : "partial",
hasSubagents,
- inputTokens: usage.inputTokens,
- outputTokens: usage.outputTokens,
+ inputTokens,
+ outputTokens,
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
...(cacheCreationTokens === undefined ? {} : { cacheCreationTokens }),
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
@@ -264,9 +271,25 @@ export interface AcpAdapterV2Flavor {
readonly buildPrompt?: (input: {
readonly text: string;
readonly attachments: ReadonlyArray;
+ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"];
+ readonly agentVersion: string | undefined;
+ /** The session's model after selection, with its display name when the agent lists it. */
+ readonly currentModel: { readonly id: string; readonly name: string | undefined } | undefined;
}) => Effect.Effect, EffectAcpErrors.AcpError>;
readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure;
+ /**
+ * Runs before `session/prompt`. The returned check sees a prompt the agent ended
+ * without an error or cancel, and reports a failure its stop reason hid. A reported
+ * failure restarts the runtime before the next turn.
+ */
+ readonly watchPromptOutcome?: (
+ sessionId: string,
+ ) => Effect.Effect<
+ (
+ result: EffectAcpSchema.PromptResponse,
+ ) => Effect.Effect
+ >;
readonly requiresSignIn?: (cause: unknown) => boolean;
/**
* Runs before attachment paths, T3 instructions and native slash-command detection,
@@ -405,6 +428,8 @@ export interface AcpAdapterV2Flavor {
* leaves usage semantics open, so this stays unset until proven live.
*/
readonly reportsPerTurnPromptUsage?: boolean;
+ /** Gemini-style usage: inputTokens leaves out cachedReadTokens, outputTokens leaves out thoughtTokens. */
+ readonly geminiPromptUsage?: boolean;
/** Grok can retain task_already_running until process exit. */
readonly restartRuntimeAfterInterrupt?: boolean;
/** Prompt failures after which the agent process keeps failing until replaced. */
@@ -421,6 +446,13 @@ export interface AcpAdapterV2Flavor {
* fail before `session/prompt`.
*/
readonly supportsImagePrompts?: boolean;
+ /**
+ * True when this session's native system prompt carries T3's PR-linking instructions, so
+ * turns omit them. Must use the same negotiated check that sent them on session setup.
+ */
+ readonly sessionCarriesPullRequestLinking?: (
+ initializeResult: EffectAcpSchema.InitializeResponse,
+ ) => boolean;
}
function acpSupportsImagePrompts(input: {
@@ -862,12 +894,45 @@ function structuredFileChanges(toolCall: AcpToolCallState) {
// Bound diff search to protect the event loop; empty-sided diffs need no search.
const ACP_V1_DIFF_MAX_EDITS = 1_000;
-export function acpToolCallDiffPatch(content: unknown): string | undefined {
+/**
+ * A `snippet` patch has no known location. Keep its recorded text, so no view numbers it
+ * from line 1.
+ */
+export function acpToolCallSnippetEdit(content: unknown) {
if (!Array.isArray(content)) return undefined;
const diffs = content.flatMap((entry) => {
const diff = unknownRecord(entry);
return diff?.type === "diff" ? [diff] : [];
});
+ const diff = diffs.length === 1 ? diffs[0] : undefined;
+ if (diff === undefined || unknownRecord(diff.patch)?.format !== "snippet") return undefined;
+ return typeof diff.oldText === "string" && typeof diff.newText === "string"
+ ? { oldStr: diff.oldText, newStr: diff.newText }
+ : undefined;
+}
+
+/**
+ * No preview when a flavor marked a diff `omitted` (too large) or `snippet` (line unknown);
+ * the checkpoint diff shows it.
+ */
+function acpToolCallDiffUnpreviewable(content: unknown): boolean {
+ return (
+ Array.isArray(content) &&
+ content.some((entry) => {
+ const format = unknownRecord(unknownRecord(entry)?.patch)?.format;
+ return format === "omitted" || format === "snippet";
+ })
+ );
+}
+
+export function acpToolCallDiffPatch(content: unknown): string | undefined {
+ if (!Array.isArray(content) || acpToolCallDiffUnpreviewable(content)) {
+ return undefined;
+ }
+ const diffs = content.flatMap((entry) => {
+ const diff = unknownRecord(entry);
+ return diff?.type === "diff" ? [diff] : [];
+ });
for (const diff of diffs) {
const patch = unknownRecord(diff.patch);
if (typeof patch?.text === "string") {
@@ -3866,14 +3931,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
}
case "edit":
case "delete":
- case "move":
+ case "move": {
+ const snippet = acpToolCallSnippetEdit(toolCall.data.content);
+ const unpreviewable = acpToolCallDiffUnpreviewable(toolCall.data.content);
turnItem = {
...base,
type: "file_change",
fileName: path ?? toolCall.title ?? "File change",
- ...(diffText === undefined ? {} : { diffStr: diffText }),
+ ...(snippet ??
+ (unpreviewable || diffText === undefined ? {} : { diffStr: diffText })),
};
break;
+ }
case "fetch": {
// Grok rawOutput.Content is unreadable by textFromUnknown; bounded content blocks duplicate it.
const snippet =
@@ -7019,6 +7088,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
context.promptUsage,
context.subagents.size > 0,
status,
+ flavor.geminiPromptUsage,
),
}),
});
@@ -7341,11 +7411,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
}
if (flavor.buildPrompt) {
const setup = yield* Ref.get(activeSessionSetup);
+ const modelOption = findModelConfigOption(yield* runtime.getConfigOptions);
+ const currentModelId = modelOption?.currentValue;
const blocks = yield* flavor
.buildPrompt({
text,
attachments: turnInput.message.attachments,
+ resolveAttachmentPath: host.resolveAttachmentPath,
promptCapabilities: setup?.initializeResult.agentCapabilities?.promptCapabilities,
+ agentVersion: setup?.initializeResult.agentInfo?.version,
+ currentModel:
+ typeof currentModelId !== "string" || modelOption === undefined
+ ? undefined
+ : {
+ id: currentModelId,
+ name: flattenSelectChoices(modelOption.options).find(
+ (choice) => choice.value === currentModelId,
+ )?.name,
+ },
})
.pipe(
Effect.mapError(
@@ -7391,11 +7474,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
});
}
if (!isNativeSlashCommand) {
+ const sessionSetup = yield* Ref.get(activeSessionSetup);
prompt.push({
type: "text",
text: buildRuntimeInstructions({
harness: flavor.runtimeHarness ?? driver,
model: turnInput.modelSelection.model,
+ pullRequestLinking:
+ sessionSetup === null ||
+ flavor.sessionCarriesPullRequestLinking?.(sessionSetup.initializeResult) !== true,
}),
});
}
@@ -7710,6 +7797,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
}
return;
}
+ const checkPromptOutcome =
+ flavor.watchPromptOutcome === undefined
+ ? undefined
+ : yield* flavor.watchPromptOutcome(requestedSessionId);
const promptGeneration = yield* Ref.get(runtimeCallbackGeneration);
yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe(
Effect.tap(() =>
@@ -7756,13 +7847,26 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
if (context.finalized || followsAgentWorkState) return;
// ACP v2 reports a failed prompt as an idle `error`, not an RPC error.
const idleError = result.stopReason === "error" && !context.interrupted;
- const status = idleError
- ? "failed"
- : result.stopReason === "cancelled"
- ? context.interrupted
- ? "interrupted"
- : "cancelled"
- : "completed";
+ const hiddenFailure =
+ checkPromptOutcome === undefined ||
+ context.interrupted ||
+ result.stopReason === "error" ||
+ result.stopReason === "cancelled"
+ ? undefined
+ : yield* checkPromptOutcome(result);
+ if (hiddenFailure !== undefined) {
+ yield* Ref.set(runtimeRestartRequired, true);
+ }
+ const failure =
+ hiddenFailure ?? (idleError ? idleErrorFailure(result.error) : undefined);
+ const status =
+ failure !== undefined
+ ? "failed"
+ : result.stopReason === "cancelled"
+ ? context.interrupted
+ ? "interrupted"
+ : "cancelled"
+ : "completed";
if (flavor.deferFinalizeForBackgroundWork === true && !context.interrupted) {
context.promptSettled = true;
context.promptSettledStatus = status;
@@ -7771,11 +7875,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
yield* finishRegisteredBackgroundTool({ taskId, ...completion });
}
}
- if (idleError) {
+ if (failure !== undefined) {
yield* terminalizeOpenForegroundTools(context, "failed");
// Unlike an RPC failure, the agent survives an idle error, so its
// background tools and subagents keep reporting to this run.
- yield* finalizeTurn(context, "failed", idleErrorFailure(result.error), {
+ yield* finalizeTurn(context, "failed", failure, {
childTurnsContinue: hasSurvivingBackgroundWork(context),
});
return;
diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts
index cadf4173a60b1..8aae3a40ccae6 100644
--- a/packages/provider-core/src/server/ProviderAdapter.ts
+++ b/packages/provider-core/src/server/ProviderAdapter.ts
@@ -154,6 +154,7 @@ export const ProviderAdapterV2Event = Schema.Union([
runOrdinal: PositiveInt,
status: Schema.Literals(["completed", "interrupted", "cancelled"]),
failure: Schema.Null,
+ reason: Schema.optional(Schema.String),
threadDisposition: Schema.Literals(["reusable", "broken"]),
}),
Schema.Struct({
@@ -499,6 +500,7 @@ export type ProviderAdapterV2RollbackTarget =
readonly checkpointId: CheckpointId;
readonly appRunOrdinal: number;
readonly providerTurn: OrchestrationV2ProviderTurn;
+ readonly message?: Pick;
};
export interface ProviderAdapterV2RollbackThreadInput {
diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts
index b24f5bad04084..84cc14dc2e594 100644
--- a/packages/provider-core/src/server/failure.test.ts
+++ b/packages/provider-core/src/server/failure.test.ts
@@ -3,6 +3,7 @@ import {
NodeId,
ProviderDriverKind,
ProviderInstanceId,
+ ProviderSessionId,
ProviderSetupError,
ProviderThreadId,
ProviderTurnId,
@@ -14,6 +15,7 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import {
+ knownProviderFailureMessage,
makeProviderFailure,
makeProviderFailureTurnItem,
MAX_PROVIDER_FAILURE_MESSAGE_LENGTH,
@@ -106,6 +108,37 @@ it("preserves actionable T3 errors wrapped by turn startup", () => {
}
});
+it("names how the provider process exited and the stage it failed in, without its output", () => {
+ for (const [exit, status] of [
+ [{ signal: "SIGILL" }, " after signal SIGILL"],
+ [{ code: 134 }, " with code 134"],
+ [{}, ""],
+ [{ signal: "SIGILL Bearer signal-secret" }, ""],
+ ] as const) {
+ const sessionOpenFailure = {
+ _tag: "ProviderSessionOpenError",
+ cause: new ProviderAdapter.ProviderAdapterOpenSessionError({
+ driver: ProviderDriverKind.make("antigravity"),
+ providerSessionId: ProviderSessionId.make("provider-session:private-id"),
+ cause: {
+ _tag: "AcpProcessExitedError",
+ ...exit,
+ pid: 4242,
+ stderr: "*** SIGILL *** token=stderr-secret",
+ cause: new Error("Process interrupted with private-detail"),
+ },
+ }),
+ };
+ const expected = `The provider session could not be opened: the provider process exited${status}. Check the server logs for the provider's output, then retry the turn.`;
+ assert.equal(knownProviderFailureMessage(sessionOpenFailure), expected);
+ assert.equal(makeProviderFailure({ cause: Cause.fail(sessionOpenFailure) }).message, expected);
+ }
+ assert.equal(
+ makeProviderFailure({ cause: { _tag: "AcpProcessExitedError", code: 1 } }).message,
+ "The provider process exited with code 1. Check the server logs for the provider's output, then retry the turn.",
+ );
+});
+
it("keeps unrecognized reasons and provider text out of category messages", () => {
for (const cause of [
"Private launch command",
diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts
index 30cf077931cb0..9bdebc55d373b 100644
--- a/packages/provider-core/src/server/failure.ts
+++ b/packages/provider-core/src/server/failure.ts
@@ -110,6 +110,14 @@ function curatedCauseMessages(cause: unknown): {
case "CursorModelUnavailableError":
case "ProviderSetupError":
return { known: stringField(cause, "message"), category: categoryMessage() };
+ case "AcpProcessExitedError": {
+ const exit = `provider process exited${processExitStatus(cause)}`;
+ const stage = category === undefined ? `The ${exit}` : `${category.summary}: the ${exit}`;
+ return {
+ known: `${stage}. Check the server logs for the provider's output, then retry the turn.`,
+ category: categoryMessage(),
+ };
+ }
case "ContextHandoffDeliveryUncertainError":
return {
known:
@@ -140,6 +148,15 @@ function curatedCauseMessages(cause: unknown): {
return { known: undefined, category: categoryMessage() };
}
+// Only the structured status; stderr and the spawner cause can carry provider output.
+function processExitStatus(exited: object): string {
+ const { signal, code } = exited as Record;
+ if (typeof signal === "string" && /^SIG[A-Z0-9]{1,16}$/u.test(signal)) {
+ return ` after signal ${signal}`;
+ }
+ return typeof code === "number" && Number.isSafeInteger(code) ? ` with code ${code}` : "";
+}
+
/**
* The curated message of a domain error in a failure's cause chain, if any.
* Callers that pass their own `message` to `makeProviderFailure` check it
diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts
index 35ccaebf21e0f..7883d827d4e64 100644
--- a/packages/provider-core/src/server/maintenanceResolver.test.ts
+++ b/packages/provider-core/src/server/maintenanceResolver.test.ts
@@ -1431,6 +1431,26 @@ it.layer(
env: { PATH: "" },
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
expect(mise.update).toBeNull();
+
+ // A mise command wrapper is a symlink to the mise binary, not to the tool.
+ const miseBinary = NodePath.join(tempDir, "usr", "bin", "mise");
+ writeExecutable(miseBinary);
+ const wrapper = NodePath.join(
+ tempDir,
+ ".local",
+ "share",
+ "mise",
+ "command-wrappers",
+ "bin",
+ "package-tool",
+ );
+ NodeFS.mkdirSync(NodePath.dirname(wrapper), { recursive: true });
+ NodeFS.symlinkSync(miseBinary, wrapper);
+ const viaWrapper = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, {
+ binaryPath: wrapper,
+ env: { PATH: "" },
+ }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
+ expect(viaWrapper.update).toBeNull();
}),
);
diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts
index 718a090925637..3017bf8267594 100644
--- a/packages/provider-core/src/server/maintenanceResolver.ts
+++ b/packages/provider-core/src/server/maintenanceResolver.ts
@@ -240,10 +240,18 @@ function isYarnGlobalCommandPath(commandPath: string): boolean {
return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath));
}
-/** Version-manager installs are pinned in its config, so updating them means editing that. */
+/**
+ * Version-manager installs are pinned in its config, so updating them means editing that.
+ * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0].
+ */
function isMiseCommandPath(commandPath: string): boolean {
const normalized = normalizeCommandPath(commandPath);
- return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/");
+ return (
+ normalized.includes("/mise/installs/") ||
+ normalized.includes("/mise/shims/") ||
+ normalized.includes("/mise/command-wrappers/") ||
+ /\/mise(?:\.exe)?$/.test(normalized)
+ );
}
const winGetClaudeCommandPath =
diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts
index 30f6826278c7a..85cac547cfa37 100644
--- a/packages/provider-core/src/server/notification.ts
+++ b/packages/provider-core/src/server/notification.ts
@@ -217,6 +217,7 @@ export function notificationTurnItem(
createdBy: _createdBy,
creationSource: _creationSource,
scheduledTaskId: _scheduledTaskId,
+ modelSwitch: _modelSwitch,
...base
} = item;
return { ...base, type: "notification", ...notification };
diff --git a/packages/provider-core/src/server/runtimeInstructions.test.ts b/packages/provider-core/src/server/runtimeInstructions.test.ts
index 7e1edeb2ac006..bbc4aadb23f26 100644
--- a/packages/provider-core/src/server/runtimeInstructions.test.ts
+++ b/packages/provider-core/src/server/runtimeInstructions.test.ts
@@ -3,6 +3,17 @@ import { assert, describe, it } from "@effect/vitest";
import { buildRuntimeInstructions } from "./runtimeInstructions.ts";
describe("buildRuntimeInstructions", () => {
+ it("leaves PR linking to a session that already carries it, and keeps runtime identity", () => {
+ const instructions = buildRuntimeInstructions({
+ harness: "Antigravity",
+ model: "gemini-3.8-flash",
+ pullRequestLinking: false,
+ });
+
+ assert.include(instructions, "through the Antigravity harness, as gemini-3.8-flash.");
+ assert.notInclude(instructions, "");
+ });
+
it("links PRs the agent creates, takes over, or pushes to, and no others", () => {
const instructions = buildRuntimeInstructions({ harness: "Codex" });
diff --git a/packages/provider-core/src/server/runtimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts
index 7f7dba43c2b08..3a65db1f95060 100644
--- a/packages/provider-core/src/server/runtimeInstructions.ts
+++ b/packages/provider-core/src/server/runtimeInstructions.ts
@@ -1,4 +1,5 @@
-const PULL_REQUEST_LINKING_INSTRUCTIONS = `
+/** Session-invariant; harnesses with a native system-prompt append can carry it once per session. */
+export const PULL_REQUEST_LINKING_INSTRUCTIONS = `
Link each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.
`;
@@ -8,6 +9,8 @@ export function buildRuntimeInstructions(runtime: {
readonly model?: string | undefined;
readonly modelName?: string | undefined;
readonly reasoningEffort?: string | undefined;
+ /** False when the session's system prompt already carries the linking instructions. */
+ readonly pullRequestLinking?: boolean;
}): string {
const harness = toSingleLine(runtime.harness);
const model = toSingleLine(runtime.model ?? "");
@@ -17,7 +20,10 @@ export function buildRuntimeInstructions(runtime: {
modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model;
const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : "";
const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
- return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer. \n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
+ const runtimeInfo = `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer. `;
+ return runtime.pullRequestLinking === false
+ ? runtimeInfo
+ : `${runtimeInfo}\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
}
function toSingleLine(value: string): string {
diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts
index 7179398cf602d..3775818892502 100644
--- a/packages/provider-core/src/server/subagentProjection.ts
+++ b/packages/provider-core/src/server/subagentProjection.ts
@@ -18,6 +18,7 @@ import type {
ThreadId,
TurnItemId,
} from "@t3tools/contracts";
+import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
@@ -258,7 +259,9 @@ export function delegatedTaskProgress(projection: {
(task.completionDelivery?.state === "pending" ||
task.completionDelivery?.state === "claimed")),
) ||
- projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0);
+ projection.providerThreads.some((thread) =>
+ backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []),
+ );
const resultRun = workRuns
.filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1))
.toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0];
diff --git a/packages/provider-core/src/server/textGenerationUtils.test.ts b/packages/provider-core/src/server/textGenerationUtils.test.ts
index f7ce3eb5a2913..d82bb50df862a 100644
--- a/packages/provider-core/src/server/textGenerationUtils.test.ts
+++ b/packages/provider-core/src/server/textGenerationUtils.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
-import { sanitizeCommitSubject } from "./textGenerationUtils.ts";
+import { sanitizeCommitSubject, sanitizeThreadTitle } from "./textGenerationUtils.ts";
describe("sanitizeCommitSubject", () => {
// regression: #19181 truncation split grapheme clusters at the subject limit
@@ -14,3 +14,16 @@ describe("sanitizeCommitSubject", () => {
expect(sanitizeCommitSubject(`${prefix}${character} more`)).toBe(prefix);
});
});
+
+describe("sanitizeThreadTitle", () => {
+ it.each([
+ { name: "a split surrogate pair", prefixLength: 116, character: "🚀", retained: "" },
+ { name: "a complete surrogate pair", prefixLength: 115, character: "🚀", retained: "🚀" },
+ { name: "a BMP character", prefixLength: 116, character: "界", retained: "界" },
+ ])("truncates without splitting $name", ({ prefixLength, character, retained }) => {
+ const prefix = "a".repeat(prefixLength);
+ const title = `${prefix}${character} more`;
+
+ expect(sanitizeThreadTitle(title)).toBe(`${prefix}${retained}...`);
+ });
+});
diff --git a/packages/provider-core/src/server/textGenerationUtils.ts b/packages/provider-core/src/server/textGenerationUtils.ts
index 05b82e41f87d0..072999fd74969 100644
--- a/packages/provider-core/src/server/textGenerationUtils.ts
+++ b/packages/provider-core/src/server/textGenerationUtils.ts
@@ -74,7 +74,13 @@ export function sanitizeThreadTitle(raw: string): string {
return normalized;
}
- return `${normalized.slice(0, MAX_THREAD_TITLE_CHARS - 3).trimEnd()}...`;
+ let end = MAX_THREAD_TITLE_CHARS - 3;
+ const previous = normalized.charCodeAt(end - 1);
+ const next = normalized.charCodeAt(end);
+ if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
+ end -= 1;
+ }
+ return `${normalized.slice(0, end).trimEnd()}...`;
}
function cliLabel(cliName: string): string {
diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts
index b8559d1ab7d8a..55caff62eac86 100644
--- a/packages/provider-pi/src/server/status.test.ts
+++ b/packages/provider-pi/src/server/status.test.ts
@@ -8,6 +8,8 @@ import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import * as Fiber from "effect/Fiber";
+import * as TestClock from "effect/testing/TestClock";
import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./status.ts";
@@ -21,19 +23,22 @@ function processHandle(input: {
readonly stdout?: string;
readonly stderr?: string;
readonly exitCode?: number;
+ /** Holds stdout and the exit back until it completes, like a CLI slow to answer. */
+ readonly answered?: Effect.Effect;
}) {
+ const answered = input.answered ?? Effect.void;
const bytes = (value: string | undefined) =>
value === undefined || value.length === 0
? Stream.empty
: Stream.succeed(encoder.encode(value));
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(900_000_001),
- exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
+ exitCode: Effect.as(answered, ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
- stdout: bytes(input.stdout),
+ stdout: Stream.unwrap(Effect.as(answered, bytes(input.stdout))),
stderr: bytes(input.stderr),
all: Stream.empty,
getInputFd: () => Sink.drain,
@@ -148,6 +153,48 @@ describe("PiProvider", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);
+ it.effect.each([
+ {
+ label: "answers after 6 s",
+ versionDelay: "6 seconds",
+ status: "ready",
+ version: "0.84.3",
+ message: "could not refresh its models and commands",
+ probesAfterVersion: true,
+ },
+ {
+ label: "never answers",
+ versionDelay: "Infinity",
+ status: "error",
+ version: null,
+ message: "Pi CLI is installed but timed out while running `pi --version`.",
+ probesAfterVersion: false,
+ },
+ ] as const)("gives `pi --version` 10 s when it $label", (row) =>
+ Effect.gen(function* () {
+ const spawned: Array = [];
+ const spawner = ChildProcessSpawner.make((command) => {
+ const args = ChildProcess.isStandardCommand(command) ? command.args : [];
+ spawned.push(args.join(" "));
+ return Effect.succeed(
+ args.includes("--version")
+ ? processHandle({ stdout: "pi 0.84.3\n", answered: Effect.sleep(row.versionDelay) })
+ : processHandle({ stderr: "RPC startup failed", exitCode: 1 }),
+ );
+ });
+ const fiber = yield* checkPiProviderStatus(settings).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.forkChild,
+ );
+ yield* TestClock.adjust("10 seconds");
+ const snapshot = yield* Fiber.join(fiber);
+ assert.equal(snapshot.status, row.status);
+ assert.equal(snapshot.version, row.version);
+ assert.include(snapshot.message ?? "", row.message);
+ assert.equal(spawned.length > 1, row.probesAfterVersion);
+ }).pipe(Effect.provide(NodeServices.layer)),
+ );
+
it.effect("reports a known unauthenticated inventory when Pi returns no usable models", () =>
Effect.gen(function* () {
const spawner = yield* piDiscoverySpawner({ models: [] });
diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts
index 0beb554999f1b..1f3a91da384e6 100644
--- a/packages/provider-pi/src/server/status.ts
+++ b/packages/provider-pi/src/server/status.ts
@@ -28,6 +28,7 @@ import {
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
import {
buildServerProvider,
+ DEFAULT_TIMEOUT_MS,
isCommandMissingCause,
parseGenericCliVersion,
providerModelsFromSettings,
@@ -55,7 +56,7 @@ const PI_PRESENTATION = {
reportsContextWindow: true,
} as const;
-const VERSION_PROBE_TIMEOUT_MS = 4_000;
+const VERSION_PROBE_TIMEOUT_MS = 10_000;
const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000;
/**
* get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4.
@@ -277,7 +278,7 @@ const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailab
}),
),
),
- Effect.timeout(VERSION_PROBE_TIMEOUT_MS),
+ Effect.timeout(DEFAULT_TIMEOUT_MS),
Effect.option,
);
const missingCredentials = Option.exists(
diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts
index a32b6f28ebf40..ae06e4153e4fb 100644
--- a/packages/shared/src/String.test.ts
+++ b/packages/shared/src/String.test.ts
@@ -3,15 +3,16 @@ import { describe, expect, it } from "vite-plus/test";
import { truncate } from "./String.ts";
describe("truncate", () => {
- it("trims surrounding whitespace", () => {
- expect(truncate(" hello world ")).toBe("hello world");
+ it("leaves short text alone", () => {
+ expect(truncate("hello", 50)).toBe("hello");
});
- it("returns shorter strings unchanged", () => {
- expect(truncate("alpha", 10)).toBe("alpha");
+ it("cuts by Unicode code point so emoji stay whole", () => {
+ expect(truncate("👍👍👍", 5)).toBe("👍👍👍");
+ expect(truncate("👍👍👍👍", 3)).toBe("👍👍👍...");
});
- it("truncates long strings and appends an ellipsis", () => {
- expect(truncate("abcdefghij", 5)).toBe("abcde...");
+ it("trims before measuring", () => {
+ expect(truncate(" hello ", 5)).toBe("hello");
});
});
diff --git a/packages/shared/src/String.ts b/packages/shared/src/String.ts
index c93d0c90cb6bb..f06282c030c47 100644
--- a/packages/shared/src/String.ts
+++ b/packages/shared/src/String.ts
@@ -1,8 +1,9 @@
export function truncate(text: string, maxLength = 50): string {
const trimmed = text.trim();
- if (trimmed.length <= maxLength) {
+ const characters = Array.from(trimmed);
+ if (characters.length <= maxLength) {
return trimmed;
}
- return `${trimmed.slice(0, maxLength)}...`;
+ return `${characters.slice(0, maxLength).join("")}...`;
}
diff --git a/packages/shared/src/cloneUrl.test.ts b/packages/shared/src/cloneUrl.test.ts
new file mode 100644
index 0000000000000..16503923d8aa3
--- /dev/null
+++ b/packages/shared/src/cloneUrl.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { cloneUrlForProtocol, cloneUrlProtocol } from "./cloneUrl.ts";
+
+describe("cloneUrlForProtocol", () => {
+ it.each([
+ ["ordinary", "user:password"],
+ ["an embedded at sign", "user:pa@rt"],
+ ["multiple at signs", "user:pa@rt@word"],
+ ["encoded", "us%40er:pa%40ss%3Aword"],
+ ])("separates Azure hosts from credentials (%s)", (_label, userinfo) => {
+ const https = `https://${userinfo}@dev.azure.com/org/Project%20One/_git/repo%40name`;
+ const ssh = `ssh://${userinfo}@ssh.dev.azure.com/v3/org/Project%20One/repo%40name`;
+ expect(cloneUrlProtocol(https)).toBe("https");
+ expect(cloneUrlProtocol(ssh)).toBe("ssh");
+ expect(cloneUrlForProtocol(https, "ssh")).toBe(
+ "git@ssh.dev.azure.com:v3/org/Project%20One/repo%40name",
+ );
+ expect(cloneUrlForProtocol(ssh, "https")).toBe(
+ "https://dev.azure.com/org/Project%20One/_git/repo%40name",
+ );
+ expect(cloneUrlForProtocol(https, "https")).toBe(https);
+ expect(cloneUrlForProtocol(ssh, "ssh")).toBe(ssh);
+ });
+
+ it.each([
+ ["https", "ssh", "dev.azure.com", "org/project/_git/repo"],
+ ["ssh", "https", "ssh.dev.azure.com", "v3/org/project/repo"],
+ ["https", "ssh", "host.example", "org/repo.git"],
+ ["ssh", "https", "host.example", "org/repo.git"],
+ ] as const)(
+ "refuses credential-bearing %s URLs with custom ports on %s switches",
+ (from, to, host, path) => {
+ const url = `${from}://user:pa@rt@${host}:8443/${path}`;
+ expect(cloneUrlForProtocol(url, to)).toBeNull();
+ expect(cloneUrlForProtocol(url, from)).toBe(url);
+ },
+ );
+
+ it("drops all userinfo on generic hosts without consuming at signs in paths", () => {
+ expect(cloneUrlForProtocol("https://user:pa@rt@github.com/org/repo@branch.git", "ssh")).toBe(
+ "git@github.com:org/repo@branch.git",
+ );
+ expect(cloneUrlForProtocol("ssh://user:pa@rt@github.com/org/repo@branch.git", "https")).toBe(
+ "https://github.com/org/repo@branch.git",
+ );
+ expect(cloneUrlForProtocol("https://github.com/org/repo@branch.git", "ssh")).toBe(
+ "git@github.com:org/repo@branch.git",
+ );
+ });
+
+ it.each(["https://user:pa@rt@/org/repo", "ssh://user:pa@rt@/org/repo"])(
+ "refuses a credential-bearing URL without a hostname (%s)",
+ (url) => {
+ expect(cloneUrlProtocol(url)).toBeNull();
+ expect(cloneUrlForProtocol(url, "ssh")).toBeNull();
+ expect(cloneUrlForProtocol(url, "https")).toBeNull();
+ },
+ );
+
+ it.each([
+ "git@ssh.dev.azure.com:v3/Fabrikam/Project1/fab_repo",
+ "ssh://git@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo",
+ "git@SSH.DEV.AZURE.COM:v3/Fabrikam/Project1/fab_repo",
+ ])("uses Azure's HTTPS host and repository path for %s", (url) => {
+ expect(cloneUrlForProtocol(url, "https")).toBe(
+ "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo",
+ );
+ });
+
+ it.each([
+ "https://dev.azure.com/Fabrikam/Project1/_git/fab_repo",
+ "https://Fabrikam@dev.azure.com/Fabrikam/Project1/_git/fab_repo",
+ "https://DEV.AZURE.COM/Fabrikam/Project1/_git/fab_repo",
+ ])("uses Azure's SSH host and repository path for %s", (url) => {
+ expect(cloneUrlForProtocol(url, "ssh")).toBe(
+ "git@ssh.dev.azure.com:v3/Fabrikam/Project1/fab_repo",
+ );
+ });
+
+ it("preserves encoded Azure repository components and same-protocol URLs", () => {
+ const ssh = "git@ssh.dev.azure.com:v3/Fabrikam/Project%20One/fab%20repo";
+ const https = "https://dev.azure.com/Fabrikam/Project%20One/_git/fab%20repo";
+ expect(cloneUrlForProtocol(ssh, "https")).toBe(https);
+ expect(cloneUrlForProtocol(https, "ssh")).toBe(ssh);
+ expect(cloneUrlForProtocol(ssh, "ssh")).toBe(ssh);
+ expect(cloneUrlForProtocol(https, "https")).toBe(https);
+ });
+
+ it("refuses Azure rewrites when the project is missing or a custom port is set", () => {
+ expect(cloneUrlForProtocol("git@ssh.dev.azure.com:v3/Fabrikam/fab_repo", "https")).toBeNull();
+ expect(cloneUrlForProtocol("https://dev.azure.com/Fabrikam/_git/fab_repo", "ssh")).toBeNull();
+ expect(
+ cloneUrlForProtocol("git@ssh.dev.azure.com:v3/Fabrikam?token/Project1/fab_repo", "https"),
+ ).toBeNull();
+ expect(
+ cloneUrlForProtocol("https://dev.azure.com/Fabrikam/Project1/_git/fab_repo#main", "ssh"),
+ ).toBeNull();
+ expect(
+ cloneUrlForProtocol(
+ "ssh://git@ssh.dev.azure.com:2222/v3/Fabrikam/Project1/fab_repo",
+ "https",
+ ),
+ ).toBeNull();
+ expect(
+ cloneUrlForProtocol("https://dev.azure.com:8443/Fabrikam/Project1/_git/fab_repo", "ssh"),
+ ).toBeNull();
+ });
+
+ it("switches between scp-style SSH and HTTPS", () => {
+ expect(cloneUrlForProtocol("git@github.com:pingdotgg/t3code.git", "https")).toBe(
+ "https://github.com/pingdotgg/t3code.git",
+ );
+ expect(cloneUrlForProtocol("https://github.com/pingdotgg/t3code.git", "ssh")).toBe(
+ "git@github.com:pingdotgg/t3code.git",
+ );
+ expect(cloneUrlForProtocol("ssh://git@gitlab.com/group/sub/repo.git", "https")).toBe(
+ "https://gitlab.com/group/sub/repo.git",
+ );
+ });
+
+ it("keeps the URL as is for its own protocol and drops HTTPS credentials on switch", () => {
+ expect(cloneUrlForProtocol("https://token@github.com/a/b.git", "https")).toBe(
+ "https://token@github.com/a/b.git",
+ );
+ expect(cloneUrlForProtocol("https://token@github.com/a/b.git", "ssh")).toBe(
+ "git@github.com:a/b.git",
+ );
+ });
+
+ it("refuses rewrites that would guess wrong", () => {
+ expect(cloneUrlForProtocol("ssh://git@host.example:2222/a/b.git", "https")).toBeNull();
+ expect(cloneUrlForProtocol("https://host.example:8443/a/b.git", "ssh")).toBeNull();
+ expect(cloneUrlForProtocol("https://host.example:8443/a/b.git", "https")).toBe(
+ "https://host.example:8443/a/b.git",
+ );
+ expect(cloneUrlForProtocol("/srv/git/repo.git", "https")).toBeNull();
+ expect(cloneUrlProtocol("file:///srv/git/repo.git")).toBeNull();
+ });
+});
diff --git a/packages/shared/src/cloneUrl.ts b/packages/shared/src/cloneUrl.ts
index e4d0fa18beec4..cface92a89a3c 100644
--- a/packages/shared/src/cloneUrl.ts
+++ b/packages/shared/src/cloneUrl.ts
@@ -1,8 +1,9 @@
export type CloneProtocol = "ssh" | "https";
const SCP_LIKE_URL = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/;
-const SSH_URL = /^ssh:\/\/(?:[^@/\s]+@)?([^:/\s]+)(:\d+)?\/(.+)$/;
-const HTTPS_URL = /^https?:\/\/(?:[^@/\s]+@)?([^:/\s]+)(:\d+)?\/(.+)$/;
+// Userinfo may contain `@`; the last one before the first slash separates the host.
+const SSH_URL = /^ssh:\/\/(?:[^/\s]*@)?([^@:/\s]+)(:\d+)?\/(.+)$/;
+const HTTPS_URL = /^https?:\/\/(?:[^/\s]*@)?([^@:/\s]+)(:\d+)?\/(.+)$/;
function parseCloneUrl(
url: string,
@@ -31,6 +32,16 @@ export function cloneUrlForProtocol(url: string, protocol: CloneProtocol): strin
if (!parsed) return null;
if (parsed.protocol === protocol) return url.trim();
if (parsed.customPort) return null;
+ // Azure's transports use different hosts and put the repository marker in
+ // different positions; changing only the scheme produces an invalid clone URL.
+ if (parsed.protocol === "ssh" && parsed.host.toLowerCase() === "ssh.dev.azure.com") {
+ const azure = /^v3\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)$/.exec(parsed.path);
+ return azure ? `https://dev.azure.com/${azure[1]}/${azure[2]}/_git/${azure[3]}` : null;
+ }
+ if (parsed.protocol === "https" && parsed.host.toLowerCase() === "dev.azure.com") {
+ const azure = /^([^/?#]+)\/([^/?#]+)\/_git\/([^/?#]+)$/.exec(parsed.path);
+ return azure ? `git@ssh.dev.azure.com:v3/${azure[1]}/${azure[2]}/${azure[3]}` : null;
+ }
return protocol === "ssh"
? `git@${parsed.host}:${parsed.path}`
: `https://${parsed.host}/${parsed.path}`;
diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts
index 382289b93def2..df3119dc9248a 100644
--- a/packages/shared/src/composerContextReferences.test.ts
+++ b/packages/shared/src/composerContextReferences.test.ts
@@ -221,6 +221,16 @@ describe("provider projection", () => {
expect(project("a\n")).toContain("3 | a\n4 | \n");
});
+ it("emits no terminal lines when the captured range is inverted", () => {
+ const projected = projectComposerContextForProvider({
+ text: "[log](t3-context://v1/terminal/ctx_t)",
+ records: [{ ...terminal, lineStart: 10, lineEnd: 5, text: "a\nb\nc\nd\ne\nf" }],
+ });
+ expect(projected).toContain('');
+ expect(projected).toContain("terminal: Terminal 1\n ");
+ expect(projected).not.toContain(" | ");
+ });
+
it("emits every marker in place and each payload once, escaped, in first-reference order", () => {
const text = [
"Look at  and [T1](t3-context://v1/terminal/ctx_t).",
diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts
index 546faa3d69d86..18b12328515a1 100644
--- a/packages/shared/src/composerContextReferences.ts
+++ b/packages/shared/src/composerContextReferences.ts
@@ -253,9 +253,10 @@ function formatComposerContextProviderPayload(record: KnownComposerContextRecord
`attachmentId: ${record.attachmentId}`,
].join("\n");
case "terminal": {
+ const lineCount = Math.max(0, record.lineEnd - record.lineStart + 1);
const lines = record.text
.split("\n")
- .slice(0, record.lineEnd - record.lineStart + 1)
+ .slice(0, lineCount)
.map((line, index) => `${record.lineStart + index} | ${line}`);
return [`terminal: ${record.terminalLabel}`, ...lines].join("\n");
}
diff --git a/packages/shared/src/logging.test.ts b/packages/shared/src/logging.test.ts
index 85642f7073b62..2590825c975c5 100644
--- a/packages/shared/src/logging.test.ts
+++ b/packages/shared/src/logging.test.ts
@@ -33,7 +33,36 @@ afterEach(() => {
}
});
-effectIt.layer(NodeServices.layer)("asynchronous rotating file sink", (it) => {
+effectIt.layer(NodeServices.layer)("rotating file sinks", (it) => {
+ it.effect.each(["synchronous", "asynchronous"] as const)(
+ "%s startup prunes only canonical rotation suffixes",
+ (mode) =>
+ Effect.gen(function* () {
+ const directory = makeTempDirectory();
+ const filePath = NodePath.join(directory, "debug.log");
+ const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"];
+ for (const suffix of [...retained, "4", "50"]) {
+ NodeFS.writeFileSync(`${filePath}.${suffix}`, suffix);
+ }
+ const options = { filePath, maxBytes: 100, maxFiles: 3 };
+
+ const sink =
+ mode === "synchronous"
+ ? new RotatingFileSink(options)
+ : yield* makeAsyncRotatingFileSink(options);
+ if (sink instanceof RotatingFileSink) sink.write("current");
+ else yield* sink.write("current");
+
+ expect(NodeFS.readdirSync(directory).sort()).toEqual(
+ ["debug.log", ...retained.map((suffix) => `debug.log.${suffix}`)].sort(),
+ );
+ expect(NodeFS.readFileSync(filePath, "utf8")).toBe("current");
+ for (const suffix of retained) {
+ expect(NodeFS.readFileSync(`${filePath}.${suffix}`, "utf8")).toBe(suffix);
+ }
+ }),
+ );
+
it.effect("serializes concurrent appends across rotation and resumes an existing file", () =>
Effect.gen(function* () {
const directory = makeTempDirectory();
diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts
index ce0d03ad6d25e..69888b53c2a1a 100644
--- a/packages/shared/src/logging.ts
+++ b/packages/shared/src/logging.ts
@@ -150,8 +150,10 @@ export class RotatingFileSink {
const baseName = NodePath.basename(this.filePath);
for (const entry of NodeFS.readdirSync(dir)) {
if (!entry.startsWith(`${baseName}.`)) continue;
- const suffix = Number(entry.slice(baseName.length + 1));
- if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue;
+ const suffixText = entry.slice(baseName.length + 1);
+ const suffix = Number(suffixText);
+ if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= this.maxFiles)
+ continue;
NodeFS.rmSync(NodePath.join(dir, entry), { force: true });
}
} catch (cause) {
@@ -212,8 +214,10 @@ export const makeAsyncRotatingFileSink = Effect.fnUntraced(function* (
yield* Effect.gen(function* () {
for (const entry of yield* fs.readDirectory(directory)) {
if (!entry.startsWith(`${basename}.`)) continue;
- const suffix = Number(entry.slice(basename.length + 1));
- if (!Number.isInteger(suffix) || suffix <= options.maxFiles) continue;
+ const suffixText = entry.slice(basename.length + 1);
+ const suffix = Number(suffixText);
+ if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= options.maxFiles)
+ continue;
yield* fs.remove(path.join(directory, entry), { force: true });
}
}).pipe(Effect.mapError(failure("prune")));
diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts
index 214f55f8f4273..5d699ffe8ab6a 100644
--- a/packages/shared/src/markdownPipeline.test.ts
+++ b/packages/shared/src/markdownPipeline.test.ts
@@ -3,7 +3,120 @@ import { unified } from "unified";
import rehypeRaw from "rehype-raw";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
-import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS } from "./markdownPipeline.ts";
+import type { Root, RootContent } from "mdast";
+import {
+ CHAT_MARKDOWN_REHYPE_PLUGINS,
+ CHAT_MARKDOWN_REMARK_PLUGINS,
+ CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
+} from "./markdownPipeline.ts";
+
+it.each([
+ ["default", CHAT_MARKDOWN_REMARK_PLUGINS],
+ ["with breaks", CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS],
+] as const)("preserves trailing underscores and tildes in bare URLs (%s)", (_, plugins) => {
+ const processor = unified().use(remarkParse).use(plugins);
+ const shape = (node: Root | RootContent): string => {
+ if (node.type === "text") return node.value;
+ if (node.type === "inlineCode") return `\`${node.value}\``;
+ if (node.type === "code") return `\`\`\`${node.value}\`\`\``;
+ const children = "children" in node ? node.children.map(shape).join("") : "";
+ if (node.type === "link") return `[${children}](${node.url})`;
+ if (node.type === "linkReference") return `[${children}][${node.identifier}]`;
+ if (node.type === "emphasis") return `*${children}*`;
+ if (node.type === "strong") return `**${children}**`;
+ if (node.type === "delete") return `~~${children}~~`;
+ return children;
+ };
+ const link = (url: string) => `[${url}](${/^www\./i.test(url) ? `http://${url}` : url})`;
+ for (const url of [
+ "https://x.com/dimasike_",
+ "http://example.com/name__",
+ "HTTPS://example.com/name_",
+ "https://example.com/a_(b)_",
+ "https://example.com/?name=value_",
+ "https://example.com/#section_",
+ "https://example.com/a_?name=value_#section_",
+ "https://example.com/name~",
+ "https://example.com/name~~",
+ "https://example.com/name_~",
+ "https://example.com/name~_",
+ "https://example.com/name__~~_",
+ "https://example.com/?name=value~",
+ "https://example.com/#section~",
+ "www.example.com/name_",
+ "www.example.com/name~_",
+ "WWW.example.com/name~~",
+ "https://example.com/name-",
+ "https://example.com/name/",
+ "https://example.com/name%5F",
+ "https://example.com/name%7E",
+ ]) {
+ for (const punctuation of ["", ".", ",", "!", "?", ":", ";", ")", "]", '"']) {
+ const markdown = `See ${url}${punctuation} next`;
+ expect(shape(processor.runSync(processor.parse(markdown), markdown) as Root), markdown).toBe(
+ `See ${link(url)}${punctuation} next`,
+ );
+ }
+ }
+ for (const [markdown, expected] of [
+ ["*https://x.com/dimasike_*", `*${link("https://x.com/dimasike_")}*`],
+ ["**https://x.com/dimasike_**", `**${link("https://x.com/dimasike_")}**`],
+ ["_https://x.com/dimasike_", `*${link("https://x.com/dimasike")}*`],
+ ["__https://x.com/dimasike__", `**${link("https://x.com/dimasike")}**`],
+ ["~~https://example.com/name_~~", `~~${link("https://example.com/name_")}~~`],
+ ["~~https://example.com/name~~", `~~${link("https://example.com/name")}~~`],
+ ["~https://example.com/name~", `~~${link("https://example.com/name")}~~`],
+ ["*https://example.com/name~*", `*${link("https://example.com/name~")}*`],
+ ["_https://example.com/name~_", `*${link("https://example.com/name~")}*`],
+ ["[test](https://example.com/name~)", "[test](https://example.com/name~)"],
+ ["[https://x.com/a](https://x.com/a)~_", `${link("https://x.com/a")}~_`],
+ ["~_", `${link("https://x.com/a")}~_`],
+ ["[test][ref]~_\n\n[ref]: https://x.com/a~_", "[test][ref]~_"],
+ ["`https://example.com/name~_`", "`https://example.com/name~_`"],
+ ["```\nhttps://example.com/name~_\n```", "```https://example.com/name~_```"],
+ ["https\\://example.com/name~_", link("https://example.com/name~_")],
+ ["https://example.com/name. _~", `${link("https://example.com/name")}. _~`],
+ ["Mail person@example.com_", "Mail person@example.com_"],
+ ["Mail person@example.com ~_", "Mail [person@example.com](mailto:person@example.com) ~_"],
+ ["[test](https://x.com/dimasike_)", "[test](https://x.com/dimasike_)"],
+ ["[https://x.com/a](https://x.com/a)_", `${link("https://x.com/a")}_`],
+ ["_", `${link("https://x.com/a")}_`],
+ ["[test][ref]_\n\n[ref]: https://x.com/a_", "[test][ref]_"],
+ ["`https://x.com/dimasike_`", "`https://x.com/dimasike_`"],
+ ["```\nhttps://x.com/dimasike_\n```", "```https://x.com/dimasike_```"],
+ ["https://x.com/a. _", `${link("https://x.com/a")}. _`],
+ ["https\\://x.com/dimasike_", link("https://x.com/dimasike_")],
+ ["https://x.com/a\\_", link("https://x.com/a\\_")],
+ ["https://x.com/a_", `${link("https://x.com/a_")};`],
+ [
+ "https://x.com/a_ https://x.com/b_",
+ `${link("https://x.com/a_")} ${link("https://x.com/b_")}`,
+ ],
+ ] as const) {
+ expect(shape(processor.runSync(processor.parse(markdown), markdown) as Root), markdown).toBe(
+ expected,
+ );
+ }
+ for (const url of ["https://x.com/dimasike_", "www.example.com/name~_"]) {
+ const markdown = `${url}, next`;
+ const boundary = { line: 1, column: url.length + 1, offset: url.length };
+ expect(processor.runSync(processor.parse(markdown), markdown)).toMatchObject({
+ children: [
+ {
+ children: [
+ {
+ type: "link",
+ url: /^www\./i.test(url) ? `http://${url}` : url,
+ position: { end: boundary },
+ children: [{ type: "text", value: url, position: { end: boundary } }],
+ },
+ { type: "text", value: ", next", position: { start: boundary } },
+ ],
+ },
+ ],
+ });
+ }
+});
it("preserves in-app thread links through the shared Markdown sanitizer", () => {
const processor = unified()
@@ -139,3 +252,41 @@ it("keeps an image on its own line under a label but leaves inline images inline
expect(render("Built [](/ci)\nand wrapped")).toBe("Built \nand wrapped");
expect(render("Hard-wrapped\nprose")).toBe("Hard-wrapped\nprose");
});
+
+it("moves a 's color-scheme sources onto its , which the renderer resolves", () => {
+ const processor = unified()
+ .use(remarkParse)
+ .use(CHAT_MARKDOWN_REMARK_PLUGINS)
+ .use(remarkRehype, { allowDangerousHtml: true })
+ .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
+ type Node = { type: string; tagName?: string; properties?: object; children?: Node[] };
+ const tags = (node: Node): string[] => [
+ ...(node.tagName ? [node.tagName] : []),
+ ...(node.children ?? []).flatMap(tags),
+ ];
+ const images = (node: Node): Node[] =>
+ node.tagName === "img" ? [node] : (node.children ?? []).flatMap(images);
+ const tree = processor.runSync(
+ processor.parse(
+ [
+ "",
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ " ",
+ "",
+ ' ',
+ "",
+ String.raw` `,
+ ].join("\n"),
+ ),
+ ) as Node;
+
+ expect(tags(tree)).toEqual(["picture", "img", "p", "picture", "img", "p", "picture", "img"]);
+ expect(images(tree).map((image) => image.properties)).toEqual([
+ { src: "docs/light.webp", alt: "Studio", dataDarkSrc: "docs/dark.webp" },
+ { src: "docs/dark.webp" },
+ { dataLocalSrc: String.raw`C:\shots\light.png`, dataDarkSrc: "file:///C:/shots/dark.png" },
+ ]);
+});
diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts
index 89ebdcf60897c..0d093872c0775 100644
--- a/packages/shared/src/markdownPipeline.ts
+++ b/packages/shared/src/markdownPipeline.ts
@@ -1,6 +1,6 @@
import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast";
import { remarkKeepWindowsPaths } from "./markdownWindowsPaths.ts";
-import { type PluggableList, type Processor, unified } from "unified";
+import { type PluggableList, type Processor, type Transformer, unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { find, html } from "property-information";
@@ -109,11 +109,44 @@ function rehypePreserveBareAnchorPlaceholders() {
};
}
+const COLOR_SCHEME_MEDIA = /^\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/i;
+
+/**
+ * The renderer resolves an ` `'s `src` (workspace files, host media) but never a ``'s
+ * `srcset`, which would load unresolved whenever its media matched. A color-scheme source moves
+ * onto the ` ` for the renderer to pick by theme; every source is then dropped.
+ */
+function moveColorSchemeSourcesToImage(picture: MarkdownImageHastNode) {
+ if (!picture.children) return;
+ const image = picture.children.find((child) => child.tagName === "img");
+ for (const source of picture.children) {
+ if (source.tagName !== "source" || !image) continue;
+ const media = source.properties?.media;
+ const scheme = typeof media === "string" ? COLOR_SCHEME_MEDIA.exec(media)?.[1] : undefined;
+ const srcSet = source.properties?.srcSet;
+ const candidate = Array.isArray(srcSet) ? srcSet[0] : srcSet;
+ // A candidate's URL runs to the first space (a descriptor such as `2x` may follow).
+ const url =
+ typeof candidate === "string" ? candidate.trim().split(/\s/, 1)[0]?.replace(/,+$/, "") : "";
+ const key = scheme?.toLowerCase() === "dark" ? "dataDarkSrc" : "dataLightSrc";
+ // As in a browser, the first source that matches a scheme is the one shown.
+ if (scheme && url && image.properties?.[key] === undefined) {
+ // The sanitizer reads a drive letter as a protocol; as a file URL it resolves to the same path.
+ const value = isWindowsDrivePathHref(url) ? `file:///${url.replaceAll("\\", "/")}` : url;
+ image.properties = { ...image.properties, [key]: value };
+ }
+ }
+ picture.children = picture.children.filter((child) => child.tagName !== "source");
+}
+
function rehypePreserveImageSourceMeta() {
return (tree: MarkdownImageHastNode) => {
const visit = (node: MarkdownImageHastNode) => {
const src = node.properties?.src;
const title = node.properties?.title;
+ if (node.type === "element" && node.tagName === "picture") {
+ moveColorSchemeSourcesToImage(node);
+ }
if (node.type === "element" && node.tagName === "img") {
node.properties = {
...node.properties,
@@ -129,6 +162,8 @@ function rehypePreserveImageSourceMeta() {
};
}
+const IMAGE_SRC_PROTOCOLS = [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"];
+
const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
@@ -145,6 +180,8 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
img: [
...(defaultSchema.attributes?.img ?? []),
+ "dataDarkSrc",
+ "dataLightSrc",
"dataLocalSrc",
"dataMarkdownTitle",
"dataStandalone",
@@ -159,12 +196,15 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
"t3-context",
THREAD_LINK_PROTOCOL,
],
- src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
+ src: IMAGE_SRC_PROTOCOLS,
+ dataDarkSrc: IMAGE_SRC_PROTOCOLS,
+ dataLightSrc: IMAGE_SRC_PROTOCOLS,
},
} satisfies Parameters[0];
export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
remarkGfm,
+ remarkKeepAutolinkSuffixes,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
@@ -176,6 +216,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [
remarkGfm,
+ remarkKeepAutolinkSuffixes,
remarkKeepWindowsPaths,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
@@ -329,6 +370,52 @@ type MarkdownAstNode = {
children?: MarkdownAstNode[];
};
+function remarkKeepAutolinkSuffixes(): Transformer {
+ return (tree, file) => {
+ const source = String(file);
+ const visit = (node: Root | RootContent) => {
+ if (!("children" in node) || node.type === "link" || node.type === "linkReference") return;
+ for (let index = 0; index < node.children.length; index++) {
+ const child = node.children[index];
+ const next = node.children[index + 1];
+ if (child?.type === "link" && /^https?:\/\//i.test(child.url) && next?.type === "text") {
+ const suffix = /^[_~]+/.exec(next.value)?.[0];
+ if (!suffix) continue;
+ const label = child.children.length === 1 ? child.children[0] : undefined;
+ if (label?.type !== "text") continue;
+ if (label.value !== child.url && `http://${label.value}` !== child.url) continue;
+ const start = child.position?.start.offset;
+ const end = child.position?.end.offset;
+ const bare =
+ !child.position ||
+ (start !== undefined &&
+ end !== undefined &&
+ source.slice(start, end) === label.value &&
+ source.slice(end, end + suffix.length) === suffix);
+ if (bare) {
+ child.url += suffix;
+ label.value += suffix;
+ next.value = next.value.slice(suffix.length);
+ if (child.position && end !== undefined) {
+ const boundary = {
+ ...child.position.end,
+ column: child.position.end.column + suffix.length,
+ offset: end + suffix.length,
+ };
+ child.position.end = boundary;
+ if (label.position) label.position.end = boundary;
+ if (next.position) next.position.start = boundary;
+ }
+ if (!next.value) node.children.splice(index + 1, 1);
+ }
+ }
+ if (child) visit(child);
+ }
+ };
+ visit(tree);
+ };
+}
+
function remarkPreserveCodeMeta() {
return (tree: MarkdownAstNode) => {
const visit = (node: MarkdownAstNode) => {
@@ -388,7 +475,12 @@ const imageSourceProcessor = unified()
interface ImageSourceNode {
readonly type: string;
readonly tagName?: string;
- readonly properties?: { readonly src?: unknown; readonly dataLocalSrc?: unknown };
+ readonly properties?: {
+ readonly src?: unknown;
+ readonly dataLocalSrc?: unknown;
+ readonly dataDarkSrc?: unknown;
+ readonly dataLightSrc?: unknown;
+ };
readonly children?: ReadonlyArray;
}
@@ -400,13 +492,17 @@ export function chatMarkdownImageSources(markdown: string): Array {
const sources: Array = [];
const visit = (node: ImageSourceNode) => {
if (node.type === "element" && node.tagName === "img") {
- const { src, dataLocalSrc } = node.properties ?? {};
+ const { src, dataLocalSrc, dataDarkSrc, dataLightSrc } = node.properties ?? {};
// The sanitizer drops a Windows drive path from `src`; the renderer reads it from here.
if (typeof dataLocalSrc === "string") {
sources.push(normalizeMarkdownLinkDestination(dataLocalSrc).replaceAll("\\", "/"));
} else if (typeof src === "string") {
sources.push(normalizeMarkdownLinkDestination(src));
}
+ // A `` shows one of these instead, by the viewer's theme.
+ for (const variant of [dataDarkSrc, dataLightSrc]) {
+ if (typeof variant === "string") sources.push(normalizeMarkdownLinkDestination(variant));
+ }
}
node.children?.forEach(visit);
};
diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts
index ac786993fa0bf..32b6e8fdf158f 100644
--- a/packages/shared/src/nodeSqliteClient.test.ts
+++ b/packages/shared/src/nodeSqliteClient.test.ts
@@ -9,6 +9,8 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as SqlClient from "effect/sql/SqlClient";
import * as Statement from "effect/sql/Statement";
+import { ChildProcess, ChildProcessSpawner } from "effect/process";
+import * as Stream from "effect/Stream";
import type * as Tracer from "effect/Tracer";
import { vi } from "vite-plus/test";
@@ -192,6 +194,34 @@ it.effect("keeps another connection from committing between a transaction's read
}).pipe(Effect.provide(NodeServices.layer)),
);
+it.effect("waits for another process's lock while the database opens", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-open-" });
+ const filename = path.join(directory, "state.sqlite");
+ const seed = new NodeSqlite.DatabaseSync(filename);
+ seed.exec("PRAGMA journal_mode = WAL; CREATE TABLE entries(name TEXT NOT NULL)");
+ seed.close();
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const holder = yield* spawner.spawn(
+ ChildProcess.make(process.execPath, [
+ "-e",
+ `const db = new (require("node:sqlite").DatabaseSync)(process.argv[1]);
+ db.exec("PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE");
+ console.log("held");
+ setTimeout(() => db.close(), 300);`,
+ filename,
+ ]),
+ );
+ yield* Stream.runHead(holder.stdout);
+ yield* Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ assert.deepEqual(yield* sql`SELECT COUNT(*) FROM entries`.values, [[0]]);
+ }).pipe(Effect.provide(SqliteClient.layer({ filename, checkpointWorker: true })));
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
+
it.effect("reports a transaction blocked by another writer as a lock timeout", () =>
Effect.gen(function* () {
const { filename, other } = yield* makeTempDatabase;
diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts
index c0edfdb868d1f..3190afbc7928a 100644
--- a/packages/shared/src/nodeSqliteClient.ts
+++ b/packages/shared/src/nodeSqliteClient.ts
@@ -629,6 +629,8 @@ const startCheckpointWorker = Effect.fnUntraced(function* (
yield* warn("Node SQLite lacks transaction-state or statement-authorizer support");
return undefined;
}
+ // These are the writer's first statements, before callers set their own wait.
+ writer.exec("PRAGMA busy_timeout = 5000;");
const version = String(writer.prepare("SELECT sqlite_version() AS version").get()?.version);
const [major = 0, minor = 0] = version.split(".").map(Number);
// Older SQLite treats NOOP as PASSIVE, which would checkpoint on the writer.
diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts
index 7d2bc964f9542..1b088697ec1f8 100644
--- a/packages/shared/src/paletteSearch.ts
+++ b/packages/shared/src/paletteSearch.ts
@@ -202,14 +202,17 @@ function matchToken(
): TokenMatch | null {
if (token.length === 0) return null;
let best: TokenMatch | null = null;
- const ownBoundary = !ALPHANUMERIC.test(token[0]!) && !SYMBOL.test(token[0]!);
+ let ownBoundary: boolean | undefined;
for (
let index = field.text.indexOf(token);
index !== -1;
index = field.text.indexOf(token, index + 1)
) {
// A token that opens with punctuation (".ts") brings its own boundary, as in messages.
- if (field.wordStarts[index] || ownBoundary) {
+ if (
+ field.wordStarts[index] ||
+ (ownBoundary ??= !ALPHANUMERIC.test(token[0]!) && !SYMBOL.test(token[0]!))
+ ) {
// Whole when the match ends where a word ends: "t3c" is a prefix of
// "t3code", ".ts" whole in "index.ts" but a prefix in "parser.tsx".
const end = index + token.length;
@@ -275,9 +278,12 @@ function matchToken(
* Allow them at its start ("t3co"), before three letters ("gomod"), or a whole word ("vscode").
*/
function matchJoinedWords(field: SearchField, token: string): TokenMatch | null {
- const firstWord = field.wordStarts.indexOf(1);
- for (let start = 0; start < field.text.length; start++) {
- if (!field.wordStarts[start] || field.text[start] !== token[0]) continue;
+ for (
+ let start = field.text.indexOf(token[0]!);
+ start !== -1;
+ start = field.text.indexOf(token[0]!, start + 1)
+ ) {
+ if (!field.wordStarts[start]) continue;
const runs: SearchRange[] = [];
let at = start;
let rest = token;
@@ -300,7 +306,7 @@ function matchJoinedWords(field: SearchField, token: string): TokenMatch | null
if (
last.end === wordEnd(field, last.start) ||
last.end - last.start >= 3 ||
- start === firstWord ||
+ start === field.wordStarts.indexOf(1) ||
runs.slice(0, -1).every((run) => run.end - run.start >= 3)
) {
return { score: JOINED_WORDS, start, end: last.end, letters: runs };
@@ -311,6 +317,8 @@ function matchJoinedWords(field: SearchField, token: string): TokenMatch | null
interface WeightedSearchField {
readonly value: string;
+ /** `value` folded; set on first use, so fields kept across keystrokes fold once. */
+ prepared?: SearchField;
readonly weight: number;
/** Only a whole-word match counts, as for a short query against keywords. */
readonly wholeWords?: boolean;
@@ -342,6 +350,10 @@ interface SearchFieldsMatch {
readonly primaryRanges: ReadonlyArray;
}
+function preparedField(weighted: WeightedSearchField): SearchField {
+ return (weighted.prepared ??= prepareSearchField(weighted.value));
+}
+
const FILLER_WORDS = new Set(["a", "an", "and", "for", "in", "of", "on", "the", "to", "with"]);
// Sorted, with overlapping ranges joined.
@@ -358,6 +370,25 @@ function mergeRanges(ranges: ReadonlyArray): SearchRange[] {
return merged;
}
+// A keystroke scores every thread for one query, so its tokens are split once.
+let lastQuery:
+ | { readonly query: string; readonly tokens: string[]; readonly fillerOptional: boolean }
+ | undefined;
+
+function queryTokens(normalizedQuery: string) {
+ if (lastQuery?.query !== normalizedQuery) {
+ const tokens = normalizedQuery.split(" ").filter((token) => token.length > 0);
+ // A phrase-style query ("fix the sidebar") carries filler words a name may
+ // leave out; they count where they match but are not required.
+ lastQuery = {
+ query: normalizedQuery,
+ tokens,
+ fillerOptional: tokens.some((token) => !FILLER_WORDS.has(token)),
+ };
+ }
+ return lastQuery;
+}
+
/**
* Every query token must match some field. Quality is the mean of each token's
* best weighted match, plus a bonus when the first field holds the whole query
@@ -369,23 +400,10 @@ function matchSearchFields(
options?: { readonly typos?: boolean },
): SearchFieldsMatch | null {
if (normalizedQuery.length === 0) return null;
- const prepared = fields
- .filter((field) => field.value.length > 0)
- .map((field) => ({
- field: prepareSearchField(field.value),
- weight: field.weight,
- wholeWords: field.wholeWords ?? false,
- keyword: field.keyword ?? false,
- context: field.context ?? false,
- allowMidWord: field.midWord ?? false,
- }));
- const primary = fields[0]?.value ? prepared[0]?.field : undefined;
+ const primary = fields[0]?.value ? preparedField(fields[0]) : undefined;
// An empty token would match everywhere, and loop forever in `matchToken`.
- const tokens = normalizedQuery.split(" ").filter((token) => token.length > 0);
+ const { tokens, fillerOptional } = queryTokens(normalizedQuery);
if (tokens.length === 0) return null;
- // A phrase-style query ("fix the sidebar") carries filler words a name may
- // leave out; they count where they match but are not required.
- const fillerOptional = tokens.some((token) => !FILLER_WORDS.has(token));
const primaryRanges: SearchRange[] = [];
let total = 0;
let counted = 0;
@@ -394,11 +412,14 @@ function matchSearchFields(
let best = 0;
let bestPrimary: TokenMatch | null = null;
let atWordStart = false;
- for (const { field, weight, wholeWords, keyword, context, allowMidWord } of prepared) {
+ for (const weighted of fields) {
+ if (weighted.value.length === 0) continue;
+ const { weight, wholeWords, keyword, context, midWord } = weighted;
+ const field = preparedField(weighted);
if (best >= weight && field !== primary && atWordStart) continue;
const typos = (options?.typos ?? false) && !keyword;
// Joined words only count in the name.
- const match = matchToken(field, token, typos, field === primary, allowMidWord);
+ const match = matchToken(field, token, typos, field === primary, midWord ?? false);
if (match === null || match.score < (wholeWords ? WHOLE_WORD : keyword ? 0.7 : 0)) continue;
if (field === primary) bestPrimary = match;
if (match.score >= 0.7 && !context) atWordStart = true;
@@ -801,6 +822,9 @@ function threadUsageBoost(thread: ThreadSearchSubject, now: number): number {
const REFERENCE_PREFIX_MIN = 8;
+// A number is never cut short: ".../pull/2288" is not pull request 22889.
+const notCut = (text: string, end: number) => !DIGIT.test(text[end] ?? "");
+
// IDs and URLs have many word starts, so field matching would let short queries hit every thread.
// Require a long prefix, or any long part containing a digit. Both are ASCII; lowercasing suffices.
function matchReference(
@@ -808,14 +832,8 @@ function matchReference(
thread: ThreadSearchSubject,
): SearchFieldsMatch | null {
if (normalizedQuery.length < REFERENCE_PREFIX_MIN) return null;
- const references = [
- ...(thread.id ? [thread.id] : []),
- ...(thread.pullRequests ?? []).flatMap((pullRequest) => pullRequest.references ?? []),
- ];
const anywhere = DIGIT.test(normalizedQuery);
- // A number is never cut short: ".../pull/2288" is not pull request 22889.
- const notCut = (text: string, end: number) => !DIGIT.test(text[end] ?? "");
- return references.some((reference) => {
+ const matches = (reference: string) => {
const folded = reference.toLowerCase();
const at = folded.indexOf(normalizedQuery);
// A pasted link may go on past the reference: ".../pull/22889/files".
@@ -823,7 +841,9 @@ function matchReference(
? (at !== -1 && notCut(folded, at + normalizedQuery.length)) ||
(normalizedQuery.startsWith(folded) && notCut(normalizedQuery, folded.length))
: at === 0;
- })
+ };
+ return (thread.id && matches(thread.id)) ||
+ thread.pullRequests?.some((pullRequest) => pullRequest.references?.some(matches))
? { quality: 0.6, strong: true, primaryRanges: [] }
: null;
}
@@ -935,6 +955,45 @@ function matchHint(
return rest && rest !== normalizedQuery ? hintFor(rest) : undefined;
}
+// A keystroke scores each thread's fields again, so they are listed once per subject.
+const threadFieldsCache = new WeakMap>();
+
+function threadSearchFields(thread: ThreadSearchSubject): ReadonlyArray {
+ const cached = threadFieldsCache.get(thread);
+ if (cached) return cached;
+ const fields: WeightedSearchField[] = [{ value: thread.title, weight: 1, midWord: true }];
+ for (const pullRequest of thread.pullRequests ?? []) {
+ fields.push({ value: `#${pullRequest.number}`, weight: 1 });
+ if (pullRequest.title) fields.push({ value: pullRequest.title, weight: 0.5 });
+ }
+ const branch = searchableBranch(thread.branch);
+ if (branch) fields.push({ value: branch, weight: 0.6 });
+ if (thread.projectTitle) {
+ fields.push({ value: thread.projectTitle, weight: 0.25, context: true });
+ }
+ if (thread.environmentLabel) {
+ fields.push({ value: thread.environmentLabel, weight: 0.25, context: true });
+ }
+ threadFieldsCache.set(thread, fields);
+ return fields;
+}
+
+let lastFieldQuery: { readonly query: string; readonly fieldQuery: string } | undefined;
+
+// Strip pull request prefixes and the worktree prefix so queries match the stored fields.
+function threadFieldQuery(normalizedQuery: string): string {
+ if (lastFieldQuery?.query !== normalizedQuery) {
+ lastFieldQuery = {
+ query: normalizedQuery,
+ fieldQuery: normalizedQuery
+ .replace(/^(?:pr|pull request|pull)\s*(?=#?\d)/, "")
+ .replace(/^#\s*/, "")
+ .replace(WORKTREE_BRANCH_PREFIX_PATTERN, ""),
+ };
+ }
+ return lastFieldQuery.fieldQuery;
+}
+
/**
* Scores a thread for a normalized query, or null when it does not match.
* Titles and pull request numbers count fully, pull request titles and
@@ -953,24 +1012,8 @@ export function scoreThreadSearch(
readonly picks?: boolean;
},
): ThreadSearchMatch | null {
- const fields: WeightedSearchField[] = [{ value: thread.title, weight: 1, midWord: true }];
- for (const pullRequest of thread.pullRequests ?? []) {
- fields.push({ value: `#${pullRequest.number}`, weight: 1 });
- if (pullRequest.title) fields.push({ value: pullRequest.title, weight: 0.5 });
- }
- const branch = searchableBranch(thread.branch);
- if (branch) fields.push({ value: branch, weight: 0.6 });
- if (thread.projectTitle) {
- fields.push({ value: thread.projectTitle, weight: 0.25, context: true });
- }
- if (thread.environmentLabel) {
- fields.push({ value: thread.environmentLabel, weight: 0.25, context: true });
- }
- // Strip pull request prefixes and the worktree prefix so queries match the stored fields.
- const fieldQuery = normalizedQuery
- .replace(/^(?:pr|pull request|pull)\s*(?=#?\d)/, "")
- .replace(/^#\s*/, "")
- .replace(WORKTREE_BRANCH_PREFIX_PATTERN, "");
+ const fields = threadSearchFields(thread);
+ const fieldQuery = threadFieldQuery(normalizedQuery);
const fieldMatch = matchSearchFields(fieldQuery, fields, options);
const referenceMatch = fieldMatch ? null : matchReference(normalizedQuery, thread);
const match = fieldMatch ?? referenceMatch;
@@ -1103,13 +1146,15 @@ export function rankSearchGroups<
score: (item: T, options: { readonly typos: boolean }, group: G) => M | null,
): Array<{ readonly group: G; readonly ranked: ReadonlyArray> }> {
const rank = (typos: boolean) =>
- groups.map(({ group, items }) => ({
- group,
- ranked: items.flatMap((item) => {
- const match = score(item, { typos }, group);
- return match === null ? [] : [{ item, match }];
- }),
- }));
+ groups.map(({ group, items }) => {
+ const options = { typos };
+ const ranked: Array> = [];
+ for (const item of items) {
+ const match = score(item, options, group);
+ if (match !== null) ranked.push({ item, match });
+ }
+ return { group, ranked };
+ });
let ranked = rank(false);
if (!ranked.some((entry) => entry.ranked.some(({ match }) => match.strong))) {
ranked = rank(true);
diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts
index ad7f88c1bd04f..9b0ccd8826213 100644
--- a/packages/shared/src/path.test.ts
+++ b/packages/shared/src/path.test.ts
@@ -4,6 +4,7 @@ import {
newProjectFolderName,
normalizeProjectPathForComparison,
normalizeProjectPathForDispatch,
+ resolvePastedPath,
resolveWorkspaceFilePath,
} from "./path.ts";
@@ -37,6 +38,64 @@ describe("path helpers", () => {
});
});
+describe("resolvePastedPath", () => {
+ it.each([
+ ["Finder, a space", "", "'/Users/me/Monarch Money'", "/Users/me/Monarch Money"],
+ ["Finder, an apostrophe", "", "/Users/me/Bob\\'s\\ Stuff", "/Users/me/Bob's Stuff"],
+ ["Finder, a backslash", "", "/Users/me/a\\\\\\ b", "/Users/me/a\\ b"],
+ [
+ "Finder, apostrophes around a name",
+ "",
+ "/Users/me/\\'My\\ Folder\\'",
+ "/Users/me/'My Folder'",
+ ],
+ ["Explorer, a drive", "", '"C:\\Users\\me\\My Code"', "C:\\Users\\me\\My Code"],
+ ["Explorer, a share", "", '"\\\\host\\share\\My Code"', "\\\\host\\share\\My Code"],
+ ["an apostrophe in double quotes", "", '"/Users/me/Bob\'s Stuff"', "/Users/me/Bob's Stuff"],
+ ["a shell, home-relative", "", "~/Bob\\'s\\ Stuff", "~/Bob's Stuff"],
+ ["surrounding whitespace", "", " '/Users/me/My Code'\n", "/Users/me/My Code"],
+ ["over a selection", "/Users/me/old", "'/Users/me/My Code'", "/Users/me/My Code"],
+ ["after a prefilled folder", "~/", "~/'/Users/me/My Code'", "/Users/me/My Code"],
+ ["plain, after a prefilled folder", "~/", "~//Users/me/code", "/Users/me/code"],
+ ["after a Windows folder", "C:\\Users\\", 'C:\\Users\\"D:\\My Code"', "D:\\My Code"],
+ ["a shell's quoted apostrophe", "", "'/Users/me/Bob'\\''s Work'", "/Users/me/Bob's Work"],
+ ["after a clone destination", "~/code/repo", "~/code/repo'/a b'", "/a b"],
+ ["over part of the box", "~/code/old", "~/code/'/a b'", "/a b"],
+ ])("reads the path copied from %s", (_source, previous, next, expected) => {
+ expect(resolvePastedPath(previous, next)).toBe(expected);
+ });
+
+ it.each([
+ ["typing a separator", "/Users/me", "/Users/me/"],
+ ["typing a separator after a folder", "~/", "~//"],
+ ["typing a trailing space", "/Users/My", "/Users/My "],
+ ["deleting", "/Users/me/", "/Users/me"],
+ ["pasting a relative folder", "~/", "~/Documents/code"],
+ ["a longer path over a selection", "/Users/me", "/Users/me/code"],
+ ["a longer path after a folder", "/Users/me/", "/Users/me/code"],
+ ["quoted text that is no path", "", "'hello world'"],
+ ["a quoted relative path", "", "'code/my app'"],
+ ["a search ending in a quoted path", "", "find '/tmp/repo'"],
+ ["a quote left open", "", "'/Users/me/My Code"],
+ ["a quote inside the quotes", "", "'/Users/me/it's here'"],
+ ["Windows separators", "", "C:\\Users\\me\\code"],
+ ["a Windows dot folder", "", "C:\\Users\\me\\.config"],
+ ["a Windows dot folder below home", "", "~/code\\.vscode"],
+ ["a share", "", "\\\\host\\share\\code"],
+ ["a backslash before a letter", "", "/Users/me/a\\b"],
+ ])("keeps %s", (_case, previous, next) => {
+ expect(resolvePastedPath(previous, next)).toBe(next);
+ });
+
+ it.each(["/Users/me/a\\ b", "~/code\\.vscode", "'/Users/me/My Code'"])(
+ "leaves %s typed one character at a time",
+ (typed) => {
+ const box = [...typed].reduce((text, char) => resolvePastedPath(text, text + char), "");
+ expect(box).toBe(typed);
+ },
+ );
+});
+
describe("workspaceRelativeFilePath", () => {
// regression: #9309 POSIX paths compare case-sensitively, Windows paths do not
it.each([
diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts
index 5d678800816d3..a7cce7cb88f09 100644
--- a/packages/shared/src/path.ts
+++ b/packages/shared/src/path.ts
@@ -124,6 +124,35 @@ export function isHostPath(value: string): boolean {
return isAbsolutePath(value) || value.startsWith("~/");
}
+// Finder and Explorer copy a path ready for a shell: `'/a b'`, `/a\'s\ b`, or `"C:\a b"`.
+const QUOTED_PATH_AT_END = /(?:'((?:[^']|'\\'')+)'|"([^"]+)")\s*$/;
+const SHELL_ESCAPE = /\\([\s'"\\()[\]{}<>|&;$`!*?#~^=])/g;
+
+/**
+ * What a folder box holds after a change. A full path pasted into it replaces the box, even after
+ * the text already there, because `~/'/a b'` would create that nested folder.
+ */
+export function resolvePastedPath(previous: string, next: string): string {
+ const isTyping = next.startsWith(previous)
+ ? next.length === previous.length + 1
+ : previous.startsWith(next);
+ if (isTyping) return next;
+
+ const match = QUOTED_PATH_AT_END.exec(next);
+ // Text before the quote that wasn't in the box is a search, not a folder to paste after.
+ if (match && previous.startsWith(next.slice(0, match.index).trimEnd())) {
+ const quoted = match[1]?.replaceAll("'\\''", "'") ?? match[2] ?? "";
+ if (isHostPath(quoted)) return quoted;
+ }
+
+ // Without a trailing separator, `/a` then `/a/b` is a longer path, not one pasted after a folder.
+ const afterFolder = /[/\\]$/.test(previous) && next.startsWith(previous);
+ const path = afterFolder ? next.slice(previous.length) : next;
+ if (!isHostPath(path)) return next;
+ // `\` separates folders in Windows paths, so it only escapes in `/` and `~/` paths.
+ return isWindowsAbsolutePath(path) ? path : path.replace(SHELL_ESCAPE, "$1");
+}
+
export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string {
if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path;
const base = workspaceRoot.replace(/[/\\]+$/, "");
diff --git a/packages/shared/src/proposedPlanText.ts b/packages/shared/src/proposedPlanText.ts
index 52d568898ed6b..76c5a8c31c2e2 100644
--- a/packages/shared/src/proposedPlanText.ts
+++ b/packages/shared/src/proposedPlanText.ts
@@ -5,16 +5,16 @@ export function proposedPlanTitle(planMarkdown: string): string | null {
export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
const lines = planMarkdown.trimEnd().split(/\r?\n/);
- const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
- while (sourceLines[0]?.trim().length === 0) {
- sourceLines.shift();
+ let start = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? 1 : 0;
+ while (lines[start]?.trim().length === 0) {
+ start += 1;
}
- const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
+ const firstHeadingMatch = lines[start]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") {
- sourceLines.shift();
- while (sourceLines[0]?.trim().length === 0) {
- sourceLines.shift();
+ start += 1;
+ while (lines[start]?.trim().length === 0) {
+ start += 1;
}
}
- return sourceLines.join("\n");
+ return lines.slice(start).join("\n");
}
diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts
index efab07d2e8961..ef1e63645c515 100644
--- a/packages/shared/src/shell.test.ts
+++ b/packages/shared/src/shell.test.ts
@@ -130,6 +130,13 @@ describe("readEnvironmentFromLoginShell", () => {
SSH_AUTH_SOCK: "/tmp/secretive.sock",
});
expect(execFile).toHaveBeenCalledTimes(1);
+ expect(execFile).toHaveBeenCalledWith("/bin/zsh", expect.any(Array), {
+ encoding: "utf8",
+ timeout: 5000,
+ killSignal: "SIGKILL",
+ detached: true,
+ stdio: ["ignore", "pipe", "ignore"],
+ });
});
it("omits environment variables that are missing or empty", () => {
diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts
index 105d2936fd3cf..75e2307b925b2 100644
--- a/packages/shared/src/shell.ts
+++ b/packages/shared/src/shell.ts
@@ -23,6 +23,7 @@ type ExecFileSyncLike = (
options: {
encoding: "utf8";
timeout: number;
+ killSignal?: NodeJS.Signals | number;
detached?: true;
stdio?: ["ignore", "pipe", "ignore"];
},
@@ -301,6 +302,7 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(shell, names)], {
encoding: "utf8",
timeout: 5000,
+ killSignal: "SIGKILL",
detached: true,
stdio: ["ignore", "pipe", "ignore"],
});
diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts
index 1c2683753656c..4d30e4972d605 100644
--- a/packages/shared/src/t3McpToolPresentation.test.ts
+++ b/packages/shared/src/t3McpToolPresentation.test.ts
@@ -16,6 +16,9 @@ describe("resolveT3McpToolPresentation", () => {
"mcp__t3_code__",
"mcp__t3code__",
"T3-code.",
+ "t3code.",
+ "t3-code-direct.",
+ "mcp__t3_code_direct__",
"t3_code/",
"t3code:",
"mcp_t3-code_",
diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts
index f7a2746684c51..10619770590be 100644
--- a/packages/shared/src/t3McpToolPresentation.ts
+++ b/packages/shared/src/t3McpToolPresentation.ts
@@ -84,7 +84,14 @@ function tool(
return { displayName, labels, icon, summaryAction };
}
-const T3_MCP_SERVER_ALIASES = new Set(["t3-code", "t3_code", "t3code"]);
+// Codex registers "t3-code-direct" for T3 tools kept out of Code Mode.
+const T3_MCP_SERVER_ALIASES = new Set([
+ "t3-code",
+ "t3_code",
+ "t3code",
+ "t3-code-direct",
+ "t3_code_direct",
+]);
const T3_MCP_TOOLS: Readonly> = {
link_pull_request: tool(
@@ -378,9 +385,10 @@ function resolveT3McpToolName(value: string): string | null {
: null;
}
- const namespaceMatch = /^(?t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec(
- label,
- );
+ const namespaceMatch =
+ /^(?t3[-_]code[-_]direct|t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec(
+ label,
+ );
if (namespaceMatch?.groups) {
return namespaceMatch.groups.tool ?? null;
}
diff --git a/packages/shared/src/terminalOutput.test.ts b/packages/shared/src/terminalOutput.test.ts
index 8870d422b763d..aaaf8c6335255 100644
--- a/packages/shared/src/terminalOutput.test.ts
+++ b/packages/shared/src/terminalOutput.test.ts
@@ -52,6 +52,9 @@ describe("appendTerminalOutput", () => {
expect(state.text.length).toBeLessThanOrEqual(1_000);
expect(state.text.startsWith("line ")).toBe(true);
expect(state.text.endsWith("line 1999")).toBe(true);
+ // A newline a quarter of the budget past the cut is too far to start on.
+ expect(feed(["abcdefgh\nij\nklmnop"], 8).text).toBe("klmnop");
+ expect(feed(["abcdefgh\nijk\nlmnop"], 8).text).toBe("jk\nlmnop");
});
it("stays bounded across many appends of huge output, including one endless line", () => {
@@ -103,10 +106,15 @@ describe("appendTerminalOutput edge cases from review", () => {
describe("appendTerminalOutput under adversarial output", () => {
it("gives the same result however the output is split into chunks", () => {
- const input = "abcdef\rX\u001b[31mred\u001b[0m\nnext line\r\u001b[2Kdone\n";
- const whole = feed([input], 3).text;
- for (let cut = 1; cut < input.length; cut += 1) {
- expect(feed([input.slice(0, cut), input.slice(cut)], 3).text).toBe(whole);
+ const input =
+ "abcdef\rX\u001b[31mred\u001b[0m\nnext line\r\u001b[2Kdone\n" +
+ "\u001b[1;31mbold red\u001b[mplain\u001b[38:2::12:34:56mrgb\u001b[0m\n" +
+ "\u001b[1;31mbold red again\u001b[mplain again\u001b[38:2::12:34:56mrgb again\u001b[0m";
+ for (const maxChars of [3, 1_024]) {
+ const whole = feed([input], maxChars).text;
+ for (let cut = 1; cut < input.length; cut += 1) {
+ expect(feed([input.slice(0, cut), input.slice(cut)], maxChars).text).toBe(whole);
+ }
}
expect(feed(["abcdef", "\rX"], 3).text).toBe(feed(["abcdef\rX"], 3).text);
});
diff --git a/packages/shared/src/terminalOutput.ts b/packages/shared/src/terminalOutput.ts
index 9172554e07336..b930282f77786 100644
--- a/packages/shared/src/terminalOutput.ts
+++ b/packages/shared/src/terminalOutput.ts
@@ -17,8 +17,9 @@ interface Run {
type Line = ReadonlyArray;
interface TerminalScreen {
- /** Scrolled-out lines, each ending in newline. */
- readonly settled: string;
+ /** Scrolled-out lines, each ending in newline, as head + tail. Only the tail grows, so a cut
+ * usually slices the head instead of copying all scrollback on every chunk. */
+ readonly settled: readonly [head: string, tail: string];
/** Never empty. */
readonly lines: ReadonlyArray;
readonly row: number;
@@ -31,7 +32,7 @@ export const EMPTY_TERMINAL_OUTPUT: TerminalOutputState = {
text: "",
truncated: false,
pending: "",
- screen: { settled: "", lines: [[]], row: 0, col: 0, pen: "", saved: null },
+ screen: { settled: ["", ""], lines: [[]], row: 0, col: 0, pen: "", saved: null },
};
/** Default command tail, in UTF-16 code units. */
@@ -289,7 +290,8 @@ function csiParams(params: string): Array {
}
class Terminal {
- settled: string;
+ settledHead: string;
+ settledTail: string;
lines: Array;
row: number;
col: number;
@@ -303,7 +305,7 @@ class Terminal {
constructor(screen: TerminalScreen, maxChars: number) {
this.maxChars = maxChars;
- this.settled = screen.settled;
+ [this.settledHead, this.settledTail] = screen.settled;
this.lines = screen.lines.slice();
this.row = screen.row;
this.col = screen.col;
@@ -374,9 +376,12 @@ class Terminal {
}
settle(line: Line): void {
- this.settled += `${serializeLine(line)}\n`;
- if (this.settled.length > 2 * this.maxChars) {
- this.settled = terminalOutputTail(this.settled, this.maxChars).text;
+ this.settledTail += `${serializeLine(line)}\n`;
+ if (this.settledHead.length + this.settledTail.length > 2 * this.maxChars) {
+ [this.settledHead, this.settledTail = ""] = terminalOutputTailParts(
+ [this.settledHead, this.settledTail],
+ this.maxChars,
+ ).parts;
this.dropped = true;
}
}
@@ -389,22 +394,22 @@ class Terminal {
csi(params: string, final: string): void {
if (/^[?>=]/u.test(params)) return; // Mode switches (cursor visibility, bracketed paste).
+ if (final === "m") {
+ const key = `${this.pen}|${params}`;
+ let pen = penTransitions.get(key);
+ if (pen === undefined) {
+ const attributes = parsePen(this.pen);
+ applySgr(attributes, params === "" ? [] : csiParams(params));
+ pen = formatPen(attributes);
+ if (penTransitions.size >= 512) penTransitions.clear();
+ penTransitions.set(key, pen);
+ }
+ this.pen = pen;
+ return;
+ }
const numbers = params === "" ? [] : csiParams(params);
const count = Math.max(1, numbers[0] || 1);
switch (final) {
- case "m": {
- const key = `${this.pen}|${params}`;
- let pen = penTransitions.get(key);
- if (pen === undefined) {
- const attributes = parsePen(this.pen);
- applySgr(attributes, numbers);
- pen = formatPen(attributes);
- if (penTransitions.size >= 512) penTransitions.clear();
- penTransitions.set(key, pen);
- }
- this.pen = pen;
- return;
- }
case "A":
return this.moveTo(this.row - count, this.col);
case "B":
@@ -435,8 +440,9 @@ class Terminal {
for (let row = 0; row < this.row; row += 1) this.lines[row] = [];
this.lines[this.row] = eraseLine(this.lines[this.row]!, this.col, 1);
} else if (mode === 3) {
- if (this.settled !== "") this.dropped = true;
- this.settled = "";
+ if (this.settledHead !== "" || this.settledTail !== "") this.dropped = true;
+ this.settledHead = "";
+ this.settledTail = "";
} else {
this.lines = [[]];
this.row = 0;
@@ -541,6 +547,33 @@ export function terminalOutputTail(
return { text: style.prefix + text.slice(start), truncated: true };
}
+/**
+ * terminalOutputTail of the parts' concatenation, kept as parts. V8 copies a concatenated string
+ * the first time it is searched or sliced, so a newline cut slices only the part it lands in.
+ */
+function terminalOutputTailParts(
+ parts: readonly [string, ...Array],
+ maxChars: number,
+): { readonly parts: readonly [string, ...Array]; readonly truncated: boolean } {
+ let total = 0;
+ for (const part of parts) total += part.length;
+ if (total <= maxChars) return { parts, truncated: false };
+ const start = total - maxChars;
+ let offset = 0;
+ for (let index = 0; index < parts.length; index += 1) {
+ const part = parts[index]!;
+ if (offset + part.length > start) {
+ const newline = part.indexOf("\n", Math.max(0, start - offset));
+ if (newline !== -1) {
+ if (offset + newline - start >= maxChars / 4) break;
+ return { parts: [part.slice(newline + 1), ...parts.slice(index + 1)], truncated: true };
+ }
+ }
+ offset += part.length;
+ }
+ return { parts: [terminalOutputTail(parts.join(""), maxChars).text], truncated: true };
+}
+
export function appendTerminalOutput(
state: TerminalOutputState,
chunk: string,
@@ -575,15 +608,15 @@ export function appendTerminalOutput(
}
}
if (terminal.dropped) truncated = true;
- const settled = terminalOutputTail(terminal.settled, maxChars);
+ const settled = terminalOutputTailParts([terminal.settledHead, terminal.settledTail], maxChars);
const window = terminal.lines.map(serializeLine).join("\n");
- const tail = terminalOutputTail(settled.text + window, maxChars);
+ const tail = terminalOutputTailParts([...settled.parts, window], maxChars);
return {
- text: tail.text,
+ text: tail.parts.reduce((text, part) => text + part, ""),
truncated: truncated || settled.truncated || tail.truncated,
pending,
screen: {
- settled: settled.text,
+ settled: [settled.parts[0], settled.parts[1] ?? ""],
lines: terminal.lines,
row: terminal.row,
col: terminal.col,
@@ -598,8 +631,14 @@ export function terminalOutputResumeText(state: TerminalOutputState): string {
const { screen } = state;
const lastRow = screen.lines.length - 1;
// Moves are relative to the last line, and a viewer only holds the lines in `text`.
- const shownLines = state.text.split("\n").length;
- const reachable = Math.min(LIVE_LINES, shownLines) - 1;
+ let reachable = 0;
+ let cursor = 0;
+ while (reachable < LIVE_LINES - 1) {
+ const newline = state.text.indexOf("\n", cursor);
+ if (newline === -1) break;
+ reachable += 1;
+ cursor = newline + 1;
+ }
const up = (row: number) => Math.min(lastRow - row, reachable);
const goTo = (row: number, col: number) =>
(up(row) > 0 ? `${ESC}[${up(row)}A` : "") + `${ESC}[${col + 1}G`;
diff --git a/packages/shared/src/textDirection.ts b/packages/shared/src/textDirection.ts
index 5611e41720d48..6267380d435d4 100644
--- a/packages/shared/src/textDirection.ts
+++ b/packages/shared/src/textDirection.ts
@@ -4,26 +4,43 @@ export type TextDirection = "ltr" | "rtl";
// supplementary right-to-left blocks.
const RTL_LETTER = /[-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
const LETTER = /\p{L}/u;
+const SENTENCE_END = /(?<=[.!?؟])(?,
+ tieDirection?: () => TextDirection | undefined,
+): TextDirection | undefined {
+ let rtl = 0;
+ let ltr = 0;
+ let first: TextDirection | undefined;
+ for (const direction of units) {
+ if (!direction) continue;
+ first ??= direction;
+ if (direction === "rtl") rtl += 1;
+ else ltr += 1;
+ }
+ if (rtl === ltr) return tieDirection ? tieDirection() : first;
+ return rtl > ltr ? "rtl" : "ltr";
+}
+
+function wordDirection(word: string): TextDirection | undefined {
+ const letter = LETTER.exec(word)?.[0];
+ if (!letter) return undefined;
+ return RTL_LETTER.test(letter) ? "rtl" : "ltr";
+}
/**
* `dir="auto"` misreads Arabic prose that opens with an English term.
- * Count words so mixed prose follows its main language; break ties with the first word.
+ * Each sentence follows its main language by word count, and the text follows most of its
+ * sentences, so one long English sentence cannot outvote the Arabic ones around it.
+ * Sentence ties use the whole-text word majority, then the first word.
*/
export function textDirection(text: string): TextDirection | undefined {
if (!RTL_LETTER.test(text)) return LETTER.test(text) ? "ltr" : undefined;
- let rtlWords = 0;
- let ltrWords = 0;
- let first: TextDirection | undefined;
- for (const word of text.split(/\s+/)) {
- const letter = LETTER.exec(word)?.[0];
- if (!letter) continue;
- const direction = RTL_LETTER.test(letter) ? "rtl" : "ltr";
- first ??= direction;
- if (direction === "rtl") rtlWords += 1;
- else ltrWords += 1;
- }
- if (rtlWords === ltrWords) return first;
- return rtlWords > ltrWords ? "rtl" : "ltr";
+ return majority(
+ text.split(SENTENCE_END).map((sentence) => majority(sentence.split(/\s+/).map(wordDirection))),
+ () => majority(text.split(/\s+/).map(wordDirection)),
+ );
}
const MARKDOWN_CODE_OR_URL =
diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts
index 82d981f59827c..aa1c02fd4bed2 100644
--- a/packages/shared/src/threadLinks.test.ts
+++ b/packages/shared/src/threadLinks.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vite-plus/test";
+import { describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import {
@@ -143,6 +143,22 @@ describe("thread links", () => {
).toEqual([ref("there", "t1"), ref("local", "t%3A2"), ref("local", "t:2")]);
});
+ it("lists a message's thread links on runtimes whose matchAll omits named groups, such as Hermes", () => {
+ const matchAll = String.prototype.matchAll;
+ using _hermes = vi.spyOn(String.prototype, "matchAll").mockImplementation(function (
+ this: string,
+ regexp: RegExp,
+ ) {
+ return matchAll.call(this, regexp).map((match) => {
+ delete match.groups;
+ return match;
+ });
+ });
+ expect(threadLinkRefs(" [a](t3-thread://v2/there/t2)", local)).toEqual(
+ [ref("local", "t1"), ref("there", "t2")],
+ );
+ });
+
it("writes a titleless autolink that opens that exact thread", () => {
const link = formatThreadLink(ref("env-1", "thread:delegated-task:mcp%3A1"));
expect(link).toBe("");
diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts
index a71fe4489d65e..aceb87635c238 100644
--- a/packages/shared/src/threadLinks.ts
+++ b/packages/shared/src/threadLinks.ts
@@ -97,7 +97,9 @@ export function threadLinkRefs(
messageEnvironmentId: EnvironmentId,
): ReadonlyArray {
const refs = new Map();
- for (const match of markdown.matchAll(THREAD_LINK_OUTSIDE_CODE)) {
+ // Hermes' matchAll yields matches without `groups`; exec keeps them.
+ const pattern = new RegExp(THREAD_LINK_OUTSIDE_CODE);
+ for (let match = pattern.exec(markdown); match !== null; match = pattern.exec(markdown)) {
const href = match.groups?.href ?? match.groups?.autolink;
const link = href === undefined ? null : parseThreadLinkHref(href);
if (link === null) continue;
diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts
index 86e5d3a154aae..c750c64652066 100644
--- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts
+++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts
@@ -96,6 +96,58 @@ afterEach(() => {
});
layer("GitLabPullRequestCli.layer", (it) => {
+ it.effect("batches approval reads at GitLab's page limit and skips an empty list", () =>
+ Effect.gen(function* () {
+ mockedExecute.mockImplementation(({ stdin }) => {
+ const { variables } = JSON.parse(stdin ?? "{}");
+ return Effect.succeed(
+ output(
+ JSON.stringify({
+ data: {
+ metadata: { enterprise: true },
+ project: {
+ mergeRequests: {
+ nodes: variables.iids.map((iid: string) => ({
+ iid,
+ approved: true,
+ approvedBy: { nodes: [{ username: "reviewer" }] },
+ })),
+ },
+ },
+ },
+ }),
+ ),
+ );
+ });
+ const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
+ const reference = { cwd: "/w", repository: "acme/web", host: "gitlab.internal" };
+ expect(yield* cli.getReviewDecisions({ ...reference, numbers: [] })).toEqual(new Map());
+ expect(mockedExecute).not.toHaveBeenCalled();
+ const decisions = yield* cli.getReviewDecisions({
+ ...reference,
+ numbers: Array.from({ length: 101 }, (_, index) => index + 1),
+ });
+ expect(callAt(0).host).toBe(reference.host);
+ expect(callAt(1).host).toBe(reference.host);
+ expect(decisions.size).toBe(101);
+ expect(decisions.get(101)).toBe("approved");
+ expect(mockedExecute).toHaveBeenCalledTimes(2);
+ expect(
+ [0, 1].map((index) => JSON.parse(callAt(index).stdin ?? "{}").variables.iids.length),
+ ).toEqual([100, 1]);
+ expect(argsOfCall(0)).toEqual([
+ "api",
+ "graphql",
+ "--method",
+ "POST",
+ "--input",
+ "-",
+ "--header",
+ "Content-Type: application/json",
+ ]);
+ }),
+ );
+
it.effect("asks GitLab for one row more than the page, to probe for a next page", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1))));
diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts
index 7bf5f1a749c04..5b0a8dae60774 100644
--- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts
+++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts
@@ -20,6 +20,7 @@ import type {
PullRequestReaction,
PullRequestReactionContent,
PullRequestReviewCommentDraft,
+ PullRequestReviewDecision,
PullRequestReviewPosition,
PullRequestReviewThread,
PullRequestReviewVerdict,
@@ -46,12 +47,14 @@ import {
decodeProjectLabelsJson,
decodeProjectUsersJson,
decodeRepositoryBlobsJson,
+ decodeReviewDecisionsJson,
decodeTriggerJobChecksJson,
decodeViewerJson,
gitLabAwardName,
MERGE_REQUEST_FILE_STATS_GRAPHQL_QUERY,
MERGE_REQUEST_STATS_GRAPHQL_QUERY,
REPOSITORY_BLOBS_GRAPHQL_QUERY,
+ REVIEW_DECISIONS_GRAPHQL_QUERY,
type GitLabDiffRefs,
type GitLabMergeRequestDetail,
type GitLabMergeRequestListItem,
@@ -252,6 +255,16 @@ export class GitLabPullRequestCli extends Context.Service<
readonly downstreamChecks?: boolean;
}) => Effect.Effect;
+ readonly getReviewDecisions: (input: {
+ readonly cwd: string;
+ readonly host?: string | undefined;
+ readonly repository: string;
+ readonly numbers: ReadonlyArray;
+ }) => Effect.Effect<
+ ReadonlyMap,
+ GitLabPullRequestCliError
+ >;
+
/** Line counts for the given merge requests; one with no answer is left out. */
readonly listMergeRequestStats: (input: {
readonly cwd: string;
@@ -1296,6 +1309,24 @@ export const make = Effect.gen(function* () {
}),
);
+ const reviewDecisionsOf = (input: {
+ readonly cwd: string;
+ readonly host?: string | undefined;
+ readonly repository: string;
+ readonly numbers: ReadonlyArray;
+ }): Effect.Effect<
+ ReadonlyMap,
+ GitLabPullRequestCliError
+ > =>
+ graphqlRead({
+ cwd: input.cwd,
+ host: input.host,
+ operation: "getReviewDecisions",
+ query: REVIEW_DECISIONS_GRAPHQL_QUERY,
+ variables: { fullPath: input.repository, iids: input.numbers.map(String) },
+ decode: decodeReviewDecisionsJson,
+ });
+
const viewerUsername = (input: { readonly cwd: string; readonly host?: string | undefined }) =>
api({ cwd: input.cwd, host: input.host, path: "user" }).pipe(
Effect.flatMap((result): Effect.Effect => {
@@ -1339,6 +1370,16 @@ export const make = Effect.gen(function* () {
},
),
+ getReviewDecisions: Effect.fn("GitLabPullRequestCli.getReviewDecisions")(function (input) {
+ const batches: Array> = [];
+ for (let at = 0; at < input.numbers.length; at += MAX_PAGE_SIZE) {
+ batches.push(input.numbers.slice(at, at + MAX_PAGE_SIZE));
+ }
+ return Effect.forEach(batches, (numbers) => reviewDecisionsOf({ ...input, numbers }), {
+ concurrency: 2,
+ }).pipe(Effect.map((pages) => new Map(pages.flatMap((page) => [...page]))));
+ }),
+
listMergeRequestStats,
getMergeRequestFileStats: Effect.fn("GitLabPullRequestCli.getMergeRequestFileStats")((input) =>
diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts
index d0b318f11485f..dbf25b531a23a 100644
--- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts
+++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts
@@ -112,7 +112,7 @@ it.effect.each(["merge", "disable-auto-merge"] as const)(
yield* provider.searchRepositoryAuthors(reference);
yield* provider.runAction({ ...reference, action });
- expect(readHosts).toEqual(Array(action === "merge" ? 11 : 10).fill(selectedHost));
+ expect(readHosts).toEqual(Array(action === "merge" ? 12 : 11).fill(selectedHost));
expect({ url: detail.url, mutatedHosts: mergedHosts }).toEqual({
url: `https://${selectedHost}/acme/web/-/merge_requests/7`,
mutatedHosts: [selectedHost],
@@ -187,6 +187,8 @@ describe("getChangeRequest base freshness", () => {
Effect.provide(
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
getMergeRequestDetail: () => Effect.succeed(detail),
+ getReviewDecisions: () => Effect.succeed(new Map()),
+
listMergeRequestStats: () => Effect.succeed([]),
}),
),
@@ -286,6 +288,7 @@ describe("getChangeRequest base freshness", () => {
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
getMergeRequestDetail: () => Effect.succeed(detail),
getProjectMergeCapabilities: () => Effect.die("linked threads skip merge settings"),
+ getReviewDecisions: () => Effect.succeed(new Map()),
listMergeRequestStats: () =>
Effect.succeed([{ repository: "acme/web", number: 7, additions: 40, deletions: 2 }]),
}),
@@ -333,6 +336,7 @@ describe("merge request reads", () => {
Effect.provide(
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
listMergeRequestStats: () => Effect.succeed([]),
+ getReviewDecisions: () => Effect.succeed(new Map()),
getMergeRequestDetail: (input) =>
Effect.sync(() => {
calls.detail += 1;
@@ -558,3 +562,119 @@ describe("optional activity reads", () => {
}),
);
});
+
+describe("review decisions", () => {
+ const reference = { cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 };
+ const item = {
+ number: 7,
+ title: "Merge request 7",
+ url: "https://gitlab.com/acme/web/-/merge_requests/7",
+ author: null,
+ headBranch: "feat/page",
+ baseBranch: "main",
+ state: "open" as const,
+ isDraft: false,
+ mergeability: "mergeable" as const,
+ additions: 0,
+ deletions: 0,
+ createdAt: "2026-07-01T00:00:00Z",
+ updatedAt: "2026-07-02T00:00:00Z",
+ reviewRequestLogins: [],
+ labels: [],
+ };
+ const detail = {
+ ...item,
+ body: "",
+ changedFiles: 1,
+ mergedAt: null,
+ closedAt: null,
+ reviewers: [],
+ checks: [],
+ viewerCanMerge: true,
+ reviewerIds: [],
+ };
+ const providerWith = (
+ getReviewDecisions: GitLabPullRequestCli.GitLabPullRequestCli["Service"]["getReviewDecisions"],
+ ) =>
+ make.pipe(
+ Effect.provide(
+ Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
+ listMergeRequests: () =>
+ Effect.succeed({
+ items: [item, { ...item, number: 8 }],
+ truncated: false,
+ cursorAdvance: 2,
+ }),
+ getMergeRequestDetail: () => Effect.succeed(detail),
+ getReviewDecisions,
+ listMergeRequestStats: () =>
+ Effect.succeed([{ repository: "acme/web", number: 7, additions: 40, deletions: 2 }]),
+ }),
+ ),
+ );
+ const listing = {
+ cwd: "/w",
+ repository: "acme/web",
+ host: "gitlab.com",
+ state: "open" as const,
+ involvement: "all" as const,
+ viewer: "julius",
+ limit: 2,
+ };
+
+ it.effect("puts GitLab's approval state on listed and summarized merge requests", () =>
+ Effect.gen(function* () {
+ const provider = yield* providerWith((input) => {
+ expect(input.host).toBe(reference.host);
+ return Effect.succeed(
+ new Map([
+ [7, "approved" as const],
+ [8, null],
+ ]),
+ );
+ });
+ const listed = yield* provider.listChangeRequests(listing);
+ assert.isDefined(provider.getChangeRequestSummary);
+ const summary = yield* provider.getChangeRequestSummary(reference);
+
+ expect(listed.items.map(({ reviewDecision }) => reviewDecision)).toEqual(["approved", null]);
+ expect(summary.reviewDecision).toBe("approved");
+ expect(summary.additions).toBe(40);
+ expect(summary.deletions).toBe(2);
+ }),
+ );
+
+ it.effect.each([
+ {
+ failure: "a failed read",
+ error: new GitLabPullRequestCli.GitLabMergeRequestReadError({
+ command: "glab",
+ cwd: "/w",
+ operation: "getReviewDecisions",
+ cause: null,
+ }),
+ },
+ {
+ failure: "a rate limit",
+ error: new GitLabCli.GitLabCliRateLimitError({
+ operation: "execute",
+ command: "glab",
+ cwd: "/w",
+ cause: null,
+ }),
+ },
+ ])("keeps rows and summaries with an unknown decision after $failure", ({ error }) =>
+ Effect.gen(function* () {
+ const provider = yield* providerWith(() => Effect.fail(error));
+ const listed = yield* provider.listChangeRequests(listing);
+ assert.isDefined(provider.getChangeRequestSummary);
+ const summary = yield* provider.getChangeRequestSummary(reference);
+
+ expect(listed.items.map(({ reviewDecision }) => reviewDecision)).toEqual([
+ undefined,
+ undefined,
+ ]);
+ expect(summary.reviewDecision).toBeUndefined();
+ }),
+ );
+});
diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts
index fbb8a6c21c282..9935bf17a4b37 100644
--- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts
+++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts
@@ -6,6 +6,7 @@ import * as Exit from "effect/Exit";
import type {
PullRequestCapabilities,
PullRequestReaction,
+ PullRequestReviewDecision,
PullRequestViewerPermissions,
} from "@t3tools/contracts";
@@ -180,6 +181,17 @@ export const make = Effect.gen(function* () {
}),
);
+ // The rows were already read, so even a rate limit only leaves their decision unknown.
+ const reviewDecisions = (input: Parameters[0]) =>
+ cli.getReviewDecisions(input).pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("GitLab merge request approval read failed", {
+ repository: input.repository,
+ reason: error._tag,
+ }).pipe(Effect.as>(new Map())),
+ ),
+ );
+
// REST omits line counts; the CLI recovers optional stats failures per batch.
const mergeRequestWithStats = (
input: {
@@ -228,8 +240,21 @@ export const make = Effect.gen(function* () {
filters: input.filters,
})
.pipe(
+ Effect.flatMap((batch) =>
+ reviewDecisions({
+ cwd: input.cwd,
+ host: input.host,
+ repository: input.repository,
+ numbers: batch.items.map((item) => item.number),
+ }).pipe(
+ Effect.map((decisions) => ({
+ ...batch,
+ items: batch.items.map((item) => withReviewDecision(item, decisions)),
+ continues: true,
+ })),
+ ),
+ ),
Effect.mapError(fail("listChangeRequests")),
- Effect.map((batch) => ({ ...batch, continues: true })),
),
getFileStats: (input) =>
@@ -249,7 +274,21 @@ export const make = Effect.gen(function* () {
cli.getMergeRequestDetail(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))),
getChangeRequestSummary: (input) =>
- mergeRequestWithStats(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),
+ Effect.all(
+ [
+ mergeRequestWithStats(input),
+ reviewDecisions({
+ cwd: input.cwd,
+ host: input.host,
+ repository: input.repository,
+ numbers: [input.number],
+ }),
+ ],
+ { concurrency: 2 },
+ ).pipe(
+ Effect.map(([mergeRequest, decisions]) => withReviewDecision(mergeRequest, decisions)),
+ Effect.mapError(fail("getChangeRequestSummary")),
+ ),
getChangeRequest: (input) =>
Effect.all(
@@ -483,3 +522,11 @@ export const make = Effect.gen(function* () {
return provider;
});
+
+function withReviewDecision- (
+ item: Item,
+ decisions: ReadonlyMap
,
+): Item & { readonly reviewDecision?: PullRequestReviewDecision | null } {
+ const reviewDecision = decisions.get(item.number);
+ return reviewDecision === undefined ? item : { ...item, reviewDecision };
+}
diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts
index 870fe42df4ffd..371c2e3f3c70a 100644
--- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts
+++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts
@@ -12,6 +12,7 @@ import {
decodeMergeRequestStatsJson,
decodeNotesJson,
decodeOwnAwardIdJson,
+ decodeReviewDecisionsJson,
decodeTriggerJobChecksJson,
} from "./gitLabMergeRequestJson.ts";
@@ -660,6 +661,80 @@ describe("decodeRepositoryBlobsJson", () => {
});
});
+describe("decodeReviewDecisionsJson", () => {
+ const decisionsOf = (enterprise: boolean | null, nodes: ReadonlyArray) =>
+ expectSuccess(
+ decodeReviewDecisionsJson(
+ JSON.stringify({
+ data: {
+ metadata: enterprise === null ? null : { enterprise },
+ project: { mergeRequests: { nodes } },
+ },
+ }),
+ ),
+ );
+ const approvedBy = (...usernames: ReadonlyArray) => ({
+ nodes: usernames.map((username) => ({ username })),
+ });
+
+ it.each([
+ {
+ reading: "an approval short of Enterprise approval rules",
+ enterprise: true,
+ approval: { approved: false, approvedBy: approvedBy("GitLabDuo") },
+ decision: "review-required",
+ },
+ {
+ reading: "Enterprise approval rules no one has approved against",
+ enterprise: true,
+ approval: { approved: false, approvedBy: approvedBy() },
+ decision: "review-required",
+ },
+ {
+ reading: "Enterprise approval rules met",
+ enterprise: true,
+ approval: { approved: true, approvedBy: approvedBy("julius") },
+ decision: "approved",
+ },
+ {
+ reading: "Enterprise without approval rules or approvals",
+ enterprise: true,
+ approval: { approved: true, approvedBy: approvedBy() },
+ decision: null,
+ },
+ {
+ reading: "Community Edition with an approval",
+ enterprise: false,
+ approval: { approved: true, approvedBy: approvedBy("julius") },
+ decision: "approved",
+ },
+ {
+ reading: "Community Edition without approvals",
+ enterprise: false,
+ approval: { approved: false, approvedBy: approvedBy() },
+ decision: null,
+ },
+ ])("reads $reading as $decision", ({ enterprise, approval, decision }) => {
+ expect([...decisionsOf(enterprise, [{ iid: "7", ...approval }])]).toEqual([[7, decision]]);
+ });
+
+ it("leaves out a merge request it cannot key, and a project the reader cannot see", () => {
+ expect([...decisionsOf(true, [null, { iid: "x", approved: true }])]).toEqual([]);
+ expect([
+ ...decisionsOf(null, [{ iid: "7", approved: false, approvedBy: approvedBy() }]),
+ ]).toEqual([]);
+ expect([
+ ...decisionsOf(true, [
+ { iid: "7", approved: null, approvedBy: approvedBy("julius") },
+ { iid: "8", approved: true, approvedBy: null },
+ ]),
+ ]).toEqual([]);
+ expect(
+ expectSuccess(decodeReviewDecisionsJson(JSON.stringify({ data: { project: null } }))),
+ ).toEqual(new Map());
+ });
+});
+
describe("decodeMergeRequestStatsJson", () => {
it("keys the counts by merge request number", () => {
const stats = expectSuccess(
diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts
index e8e5e664b3720..79ab379ffbd44 100644
--- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts
+++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts
@@ -15,6 +15,7 @@ import type {
PullRequestMergeMethod,
PullRequestReaction,
PullRequestReactionContent,
+ PullRequestReviewDecision,
PullRequestReviewThread,
PullRequestReviewerCandidate,
PullRequestState,
@@ -996,6 +997,87 @@ export function decodeRepositoryBlobsJson(
return Result.succeed(blobs);
}
+export const REVIEW_DECISIONS_GRAPHQL_QUERY = `query($fullPath: ID!, $iids: [String!]) {
+ metadata { enterprise }
+ project(fullPath: $fullPath) {
+ mergeRequests(iids: $iids, first: 100) {
+ nodes { iid approved approvedBy(first: 1) { nodes { username } } }
+ }
+ }
+}`;
+
+const RawReviewDecisionsSchema = Schema.Struct({
+ data: Schema.Struct({
+ metadata: Schema.optional(
+ Schema.NullOr(Schema.Struct({ enterprise: Schema.optional(Schema.NullOr(Schema.Boolean)) })),
+ ),
+ project: Schema.NullOr(
+ Schema.Struct({
+ mergeRequests: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ nodes: Schema.Array(
+ Schema.NullOr(
+ Schema.Struct({
+ iid: Schema.String,
+ approved: Schema.optional(Schema.NullOr(Schema.Boolean)),
+ approvedBy: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
+ }),
+ ),
+ ),
+ }),
+ ),
+ ),
+ }),
+ ),
+ ),
+ }),
+ ),
+ }),
+});
+
+const decodeReviewDecisions = decodeJsonResult(RawReviewDecisionsSchema);
+
+/**
+ * Only Enterprise Edition holds `approved` to the project's approval rules, so an unapproved
+ * merge request there still needs review even when someone, such as a bot, approved it.
+ * Community Edition reports `approved` once anyone approves and requires no review.
+ */
+export function decodeReviewDecisionsJson(
+ raw: string,
+): Result.Result, DecodeFailure> {
+ const decoded = decodeReviewDecisions(raw);
+ if (!Result.isSuccess(decoded)) {
+ return Result.fail(decoded.failure);
+ }
+ const enterprise = decoded.success.data.metadata?.enterprise;
+ const decisions = new Map();
+ for (const node of decoded.success.data.project?.mergeRequests?.nodes ?? []) {
+ const number = Number(node?.iid);
+ if (
+ node === null ||
+ !Number.isSafeInteger(number) ||
+ node.approved == null ||
+ node.approvedBy?.nodes == null
+ )
+ continue;
+ const approvals = node.approvedBy.nodes.length;
+ if (node.approved === false && approvals === 0 && enterprise == null) continue;
+ decisions.set(
+ number,
+ node.approved === false && (enterprise || approvals > 0)
+ ? "review-required"
+ : approvals > 0
+ ? "approved"
+ : null,
+ );
+ }
+ return Result.succeed(decisions);
+}
+
/**
* Line counts for merge requests of one project. The REST listing carries no line counts, and
* reading each diff to count them would cost a request per row, so GraphQL's per-merge-request
diff --git a/patches/@pierre%2Fdiffs@1.6.1.patch b/patches/@pierre%2Fdiffs@1.6.1.patch
index 06229d8c170e6..0ccf26ec66f70 100644
--- a/patches/@pierre%2Fdiffs@1.6.1.patch
+++ b/patches/@pierre%2Fdiffs@1.6.1.patch
@@ -904,7 +904,18 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js
index bf17cea32f..671e77b8cd 100644
--- a/dist/editor/editor.js
+++ b/dist/editor/editor.js
-@@ -1903,7 +1903,11 @@ var Editor = class {
+@@ -436,7 +436,9 @@ var Editor = class {
+ this.#canMountSelectionAction = false;
+ this.#updateSelections(resolvedSelections);
+ const primarySelection = resolvedSelections.at(-1);
+- if (primarySelection !== void 0) this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line);
++ // Clearing keeps the selection while search matches or markers exist; scrolling to it steals focus.
++ if (primarySelection === void 0) return;
++ this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line);
+ this.#scrollToPrimaryCaret();
+ }
+ setMarkers(markers) {
+@@ -1903,7 +1905,11 @@ var Editor = class {
changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,
documentLineCount: textDocument.lineCount
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a48e84824c83..6bdf7fb56ac53 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -252,7 +252,7 @@ patchedDependencies:
'@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02
'@ff-labs/fff-node@0.11.0': 765c192f75289d2a65d54011f65e3c9397603298eefb7203e7e05260352e47c8
'@legendapp/list@3.3.5': 892caa00d83adda6135574b04ea63752bcedf1d9103f013eef1fe9289a5e02a6
- '@pierre/diffs@1.6.1': cbfbe7f36010a8973f57955ed43a7ee2895db5c15e5b336dbf9e850d342d6d26
+ '@pierre/diffs@1.6.1': 55b422eb72cf142519ee504ff8078bd825c0707299e55bd835b1148c219cf7d1
mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c
mdast-util-to-hast@13.2.1: 57ea46c73aea3c876ab8d351fe0482ddd081dcb4a0610576e399dbe5eb59d2b1
micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8
@@ -470,7 +470,7 @@ importers:
version: 3.3.5(patch_hash=892caa00d83adda6135574b04ea63752bcedf1d9103f013eef1fe9289a5e02a6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@pierre/diffs':
specifier: 1.6.1
- version: 1.6.1(patch_hash=cbfbe7f36010a8973f57955ed43a7ee2895db5c15e5b336dbf9e850d342d6d26)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 1.6.1(patch_hash=55b422eb72cf142519ee504ff8078bd825c0707299e55bd835b1148c219cf7d1)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@pierre/trees':
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)
@@ -717,7 +717,7 @@ importers:
dependencies:
'@pierre/diffs':
specifier: 1.6.1
- version: 1.6.1(patch_hash=cbfbe7f36010a8973f57955ed43a7ee2895db5c15e5b336dbf9e850d342d6d26)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 1.6.1(patch_hash=55b422eb72cf142519ee504ff8078bd825c0707299e55bd835b1148c219cf7d1)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@t3tools/contracts':
specifier: workspace:*
version: link:../contracts
@@ -6018,7 +6018,7 @@ snapshots:
'@oxlint/plugins@1.89.0': {}
- '@pierre/diffs@1.6.1(patch_hash=cbfbe7f36010a8973f57955ed43a7ee2895db5c15e5b336dbf9e850d342d6d26)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
+ '@pierre/diffs@1.6.1(patch_hash=55b422eb72cf142519ee504ff8078bd825c0707299e55bd835b1148c219cf7d1)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
dependencies:
'@pierre/highlights': 0.2.0
'@pierre/theme': 2.0.0
diff --git a/scripts/seams-check.mjs b/scripts/seams-check.mjs
index 492e4372640c7..697711b8efc93 100644
--- a/scripts/seams-check.mjs
+++ b/scripts/seams-check.mjs
@@ -14,7 +14,7 @@
import { execFile, execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
-const SYNC_BASE = "8760d063e9";
+const SYNC_BASE = "47f2c8d29e";
const base = process.argv[2] ?? SYNC_BASE;
const DOC = "docs/internals/coder-only.md";
const SECTIONS = ["Upstream seams", "Cut surfaces"];
diff --git a/scripts/sync-resolve-deleted.mjs b/scripts/sync-resolve-deleted.mjs
index 5eef33e5eab10..ef425b92732f7 100644
--- a/scripts/sync-resolve-deleted.mjs
+++ b/scripts/sync-resolve-deleted.mjs
@@ -130,6 +130,10 @@ const DELETED_PATHS = {
"apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts",
"apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts",
"apps/server/src/provider/antigravityUsageLimits.ts",
+ "apps/server/src/provider/GrokProvider.test.ts",
+ // Antigravity subagent child work (#28629): the native child work query and its dialog.
+ "apps/server/src/orchestration-v2/NativeChildWorkQuery.ts",
+ "apps/web/src/components/chat/NativeChildWorkDialog.tsx",
],
// Kilo Code runs upstream's ACP Registry driver from its local command only: no registry
// installs, agent sign-in, per-agent flavors, or native protocol logging.
@@ -148,6 +152,10 @@ const DELETED_PATHS = {
// checklist, native helpers, or packaging.
desktop: [
"apps/web/src/lib/desktopPasteAsText.ts",
+ // Desktop folder path lookup for dropped and pasted folders.
+ "apps/web/src/components/chat/folderDrop.ts",
+ // Client activity leases; the helper's background policy always allows work.
+ "apps/web/src/lib/backgroundActivityReporter.ts",
"apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts",
"patches/app-builder-lib@26.16.1.patch",
"apps/desktop/",
@@ -181,6 +189,7 @@ const DELETED_PATHS = {
"apps/web/src/components/clerk/",
"apps/web/src/components/cloud/",
"apps/web/src/components/onboarding/",
+ "docs/user/t3-connect-troubleshooting.md",
"apps/web/src/components/promptLinks/copyPromptLink.ts",
],
// Server CLI: the helper starts over `coder ssh`, never as upstream's `t3` command, and
@@ -196,6 +205,7 @@ const DELETED_PATHS = {
"apps/server/src/serverRuntimeStartup.autoPull.test.ts",
"apps/server/src/serverRuntimeStartup.test.ts",
"apps/server/src/serverRuntimeState.test.ts",
+ "apps/web/src/components/settings/CliCommandSettingsRow.tsx",
],
// Local editors and provider setup: the helper cannot open an editor on the laptop, and
// providers are configured in the workspace without in-app sign-in or setup.
@@ -210,6 +220,9 @@ const DELETED_PATHS = {
"apps/server/src/provider/CodexChatGptModels.ts",
"apps/server/src/provider/CodexInstallation.ts",
"apps/web/src/components/settings/ProviderSetupSection.tsx",
+ "apps/web/src/components/settings/ProviderAuthenticationSection.tsx",
+ "docs/user/providers-claude.md",
+ "docs/user/providers-opencode.md",
"docs/user/welcome-wizard.md",
],
// Tests for cut surfaces: the provider environment panel test drives managed Codex accounts and
@@ -235,6 +248,8 @@ const DELETED_PATHS = {
"apps/web/src/components/ProviderUpdatesAction.browser.test.tsx",
// The primary-environment shell factory, which the fork's every-workspace-remote atom replaces.
"apps/web/src/state/shell.test.ts",
+ // The per-instance environment variable section, which Providers does not carry.
+ "apps/web/src/components/settings/ProviderInstanceCard.environment.browser.test.tsx",
],
// Relay: no relay, Tailscale, or SSH transport.
relay: [
@@ -275,7 +290,9 @@ const DELETED_PATHS = {
"apps/server/src/observability/Observability.ts",
"apps/server/src/observability/RpcInstrumentation.ts",
"apps/server/src/resourceTelemetry/",
- "apps/server/src/telemetry/",
+ // ProviderUsageAnalytics.ts stays as types and the no-op ProviderTurnAnalytics reference,
+ // which carried orchestration files import (#29001).
+ /^apps\/server\/src\/telemetry\/(?!ProviderUsageAnalytics\.ts$)/,
"apps/server/src/usage/",
"apps/web/src/components/usage/",
"apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts",
@@ -294,6 +311,9 @@ const DELETED_PATHS = {
"apps/server/src/provider/EventNdjsonLogger.test.ts",
"apps/server/src/serverLogger.ts",
"packages/provider-pi/src/server/usage.ts",
+ "packages/shared/src/usageMerge.ts",
+ "docs/internals/product-analytics.md",
+ "docs/user/telemetry.md",
"packages/shared/src/observability.ts",
],
// Updates and releases: no server self-update, update pill or fleet tracks, npm launcher, CLI
@@ -301,9 +321,12 @@ const DELETED_PATHS = {
"updates and releases": [
"apps/web/src/components/sidebar/updateEverythingRun.tsx",
"apps/server/src/persistence/databaseMigrationHead.ts",
+ // `t3 doctor` database maintenance (no CLI in the helper).
+ "apps/server/src/persistence/DatabaseMaintenance.ts",
"apps/server/src/serverOwnership.ts",
"apps/web/src/components/ServerUpdateAction.tsx",
"apps/web/src/components/sidebar/SidebarUpdatePill.tsx",
+ "apps/web/src/components/sidebar/SidebarUpdateCapsule.tsx",
"apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx",
"apps/web/src/components/sidebar/UpdateEverythingDialog.tsx",
"apps/web/src/components/sidebar/updateEverything.logic.ts",
@@ -332,6 +355,8 @@ const DELETED_PATHS = {
"apps/server/src/mcp/linkOrigin.test.ts",
"apps/server/src/peer/PeerForwarding.test.ts",
"apps/server/src/peer/RemoteDelegation.test.ts",
+ "apps/server/src/peer/PeerLinkRequests.ts",
+ "apps/server/src/peer/PeerLinks.test.ts",
],
// Guest sharing: no guest share links, guest page, or guest HTTP routes.
"guest sharing": [
@@ -379,6 +404,8 @@ const DELETED_PATHS = {
"other web surfaces": [
"apps/web/src/components/GitHubStarPrompt.tsx",
"apps/web/src/components/chat/hostImageUrl.ts",
+ // Add to another machine (#22180), which copies ignored files between workspaces.
+ "apps/web/src/components/AddProjectToMachineDialog.tsx",
],
// Upstream repository tooling: vendored sources, CI, hooks, editor and agent configuration,
// and operations docs. The fork's .github/ keeps only SECURITY.md.
@@ -402,6 +429,7 @@ const DELETED_PATHS = {
"docs/internals/open-source-licenses.md",
"docs/user/faq.md",
"scripts/check-changed.ts",
+ "scripts/check-changed-command.ts",
"scripts/dev-runner.ts",
"scripts/dev-runner.test.ts",
// Startup profiling (#28566) preloads into upstream's dev runner and CLI, neither carried.