diff --git a/.env.example b/.env.example
index 61cdd66d246a..fc67dcef9478 100644
--- a/.env.example
+++ b/.env.example
@@ -1,12 +1,14 @@
# Optional: T3 Connect source builds
-# Leave these unset to disable optional T3 Connect features in local source builds.
-# Release builds inject their public values at build time. Do not add server-side
-# secrets to this file.
+# `cp .env.example .env` enables T3 Connect against the production deployment.
+# These are the same public identifiers baked into official release builds, not
+# secrets. Remove or comment them out to build with cloud features disabled.
+# Do not add server-side secrets to this file.
-# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications.
-# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_...
-# T3CODE_CLERK_JWT_TEMPLATE=t3-relay
-# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_...
+# Production Clerk instance. To use your own, get these from the Clerk Dashboard
+# under API keys, JWT templates, and OAuth applications.
+T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk
+T3CODE_CLERK_JWT_TEMPLATE=t3-relay
+T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r
# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API
# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk
@@ -15,8 +17,9 @@
# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile
# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com
-# Get this from your relay deployment. `infra/relay` deploys update it automatically.
-# T3CODE_RELAY_URL=https://relay.example.com
+# Production relay. For a self-hosted relay, `infra/relay` deploys update it
+# automatically.
+T3CODE_RELAY_URL=https://relay.t3.codes
# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs
new file mode 100644
index 000000000000..94a02b7806dc
--- /dev/null
+++ b/.github/scripts/thread-transfer-report.cjs
@@ -0,0 +1,429 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+const ARTIFACT_NAME = "thread-transfer-results";
+const RESULT_FILE = "thread-transfer-result.json";
+const COMMENT_MARKER = "";
+const PROVIDERS = ["codex", "claudeAgent"];
+const OBSERVED_KEYS = [
+ "totalWireBytes",
+ "threadSnapshotWireBytes",
+ "threadSnapshotDecodedBytes",
+ "measuredTurnWebSocketWireBytes",
+ "measuredTurnWebSocketDecodedBytes",
+ "measuredTurnWebSocketMessages",
+];
+const CEILING_KEYS = [
+ "totalWireBytes",
+ "threadSnapshotWireBytes",
+ "measuredTurnWebSocketWireBytes",
+ "measuredTurnWebSocketDecodedBytes",
+ "measuredTurnWebSocketMessages",
+];
+const SCENARIO_KEYS = [
+ "id",
+ "historyTurns",
+ "historyCommandToolsPerTurn",
+ "historyMcpResultBytes",
+ "measuredCommandTools",
+ "measuredMcpResultBytes",
+];
+
+function resultShaMarker(sha) {
+ return ``;
+}
+
+function assertObject(value, label) {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`${label} must be an object`);
+ }
+}
+
+function assertExactKeys(value, expected, label) {
+ assertObject(value, label);
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
+ throw new Error(`${label} has unexpected fields`);
+ }
+}
+
+function assertMetric(value, label) {
+ if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) {
+ throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`);
+ }
+}
+
+function validateResult(value) {
+ assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result");
+ if (value.schemaVersion !== 1) {
+ throw new Error("result.schemaVersion must be 1");
+ }
+
+ assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario");
+ if (value.scenario.id !== "thread-transfer-v1") {
+ throw new Error("result.scenario.id is not supported");
+ }
+ for (const key of SCENARIO_KEYS.slice(1)) {
+ assertMetric(value.scenario[key], `result.scenario.${key}`);
+ }
+
+ assertExactKeys(value.providers, PROVIDERS, "result.providers");
+ for (const provider of PROVIDERS) {
+ const entry = value.providers[provider];
+ assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`);
+ assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`);
+ assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`);
+ for (const key of OBSERVED_KEYS) {
+ assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`);
+ }
+ for (const key of CEILING_KEYS) {
+ assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`);
+ }
+ }
+
+ return value;
+}
+
+function readResult(directory) {
+ if (!directory) return undefined;
+ const file = path.join(directory, RESULT_FILE);
+ if (!fs.existsSync(file)) return undefined;
+ const stat = fs.lstatSync(file);
+ if (!stat.isFile() || stat.size > 64 * 1_024) {
+ throw new Error("thread transfer result must be a regular file smaller than 64 KiB");
+ }
+ return validateResult(JSON.parse(fs.readFileSync(file, "utf8")));
+}
+
+function formatBytes(bytes) {
+ if (bytes < 1_024) return `${bytes} B`;
+ if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`;
+ return `${(bytes / 1_024).toFixed(1)} KiB`;
+}
+
+function formatValue(value, kind) {
+ return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value);
+}
+
+function formatImpact(current, baseline, kind) {
+ if (baseline === undefined) return "—";
+ const delta = current - baseline;
+ const prefix = delta > 0 ? "+" : delta < 0 ? "−" : "";
+ const magnitude = formatValue(Math.abs(delta), kind);
+ const percent =
+ baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`;
+ return `${prefix}${magnitude}${percent}`;
+}
+
+function sameScenario(left, right) {
+ return SCENARIO_KEYS.every((key) => left[key] === right[key]);
+}
+
+const METRICS = [
+ { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" },
+ { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" },
+ {
+ key: "measuredTurnWebSocketWireBytes",
+ label: "Live turn WebSocket wire",
+ kind: "bytes",
+ },
+ {
+ key: "measuredTurnWebSocketDecodedBytes",
+ label: "Live turn WebSocket decoded",
+ kind: "bytes",
+ },
+ { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" },
+];
+
+function renderComment(input) {
+ const current = input.current;
+ const baseline = input.baseline;
+ const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario);
+ const rows = [];
+ const ceilingChanges = [];
+ let failed = false;
+
+ for (const provider of PROVIDERS) {
+ for (const metric of METRICS) {
+ const observed = current.providers[provider].observed[metric.key];
+ const ceiling = current.providers[provider].ceiling[metric.key];
+ const baselineObserved = comparable
+ ? baseline.providers[provider].observed[metric.key]
+ : undefined;
+ const pass = observed <= ceiling;
+ failed ||= !pass;
+ rows.push(
+ `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`,
+ );
+
+ if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) {
+ ceilingChanges.push(
+ `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`,
+ );
+ }
+ }
+ }
+
+ const baselineLink = input.baselineRun
+ ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})`
+ : "unavailable";
+ const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`;
+ const notices = [];
+ if (!baseline) {
+ notices.push(
+ "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.",
+ );
+ } else if (!comparable) {
+ notices.push(
+ "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.",
+ );
+ } else if (!input.baselineRun.matchesBase) {
+ notices.push(
+ "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.",
+ );
+ }
+ if (ceilingChanges.length > 0) {
+ notices.push(
+ `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`,
+ );
+ }
+
+ return [
+ COMMENT_MARKER,
+ resultShaMarker(input.currentRun.sha),
+ "## Thread transfer impact",
+ "",
+ failed
+ ? "❌ One or more thread transfer ceilings were exceeded."
+ : "✅ Thread transfer remains within every enforced ceiling.",
+ ...(notices.length > 0 ? ["", ...notices] : []),
+ "",
+ "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |",
+ "| --- | --- | ---: | ---: | ---: | ---: | --- |",
+ ...rows,
+ "",
+ `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`,
+ "",
+ "",
+ "Scenario and decoded snapshot size
",
+ "",
+ `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`,
+ "",
+ ...PROVIDERS.map(
+ (provider) =>
+ `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`,
+ ),
+ "",
+ " ",
+ "",
+ "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._",
+ ].join("\n");
+}
+
+async function artifactsForRun(github, owner, repo, runId) {
+ return github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
+ owner,
+ repo,
+ run_id: runId,
+ per_page: 100,
+ });
+}
+
+function findResultArtifact(artifacts) {
+ return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired);
+}
+
+async function resolve({ github, context, core }) {
+ const source = context.payload.workflow_run;
+ const { owner, repo } = context.repo;
+ if (source.event !== "pull_request") {
+ core.setOutput("publish", "false");
+ return;
+ }
+
+ let pullNumber = source.pull_requests?.[0]?.number;
+ if (!pullNumber) {
+ const associated = await github.paginate(
+ github.rest.repos.listPullRequestsAssociatedWithCommit,
+ { owner, repo, commit_sha: source.head_sha, per_page: 100 },
+ );
+ const matchingPulls = associated.filter(
+ (pull) =>
+ pull.state === "open" &&
+ pull.head.sha === source.head_sha &&
+ pull.head.ref === source.head_branch,
+ );
+ if (matchingPulls.length !== 1) {
+ core.info(
+ `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`,
+ );
+ core.setOutput("publish", "false");
+ return;
+ }
+ pullNumber = matchingPulls[0].number;
+ }
+ if (!pullNumber) {
+ core.info("No open pull request is associated with the completed CI run.");
+ core.setOutput("publish", "false");
+ return;
+ }
+
+ const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
+ if (pull.head.sha !== source.head_sha) {
+ core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`);
+ core.setOutput("publish", "false");
+ return;
+ }
+
+ const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id);
+ const sourceArtifact = findResultArtifact(sourceArtifacts);
+ const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
+ owner,
+ repo,
+ workflow_id: source.workflow_id,
+ branch: pull.base.ref,
+ event: "push",
+ status: "success",
+ per_page: 100,
+ });
+ const orderedRuns = [
+ ...workflowRuns.filter((run) => run.head_sha === pull.base.sha),
+ ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha),
+ ].slice(0, 20);
+
+ let baselineRun;
+ for (const run of orderedRuns) {
+ const artifacts = await artifactsForRun(github, owner, repo, run.id);
+ if (findResultArtifact(artifacts)) {
+ baselineRun = run;
+ break;
+ }
+ }
+
+ core.setOutput("publish", "true");
+ core.setOutput("pull_number", String(pullNumber));
+ core.setOutput("pr_artifact", sourceArtifact ? "true" : "false");
+ core.setOutput("pr_run_id", String(source.id));
+ core.setOutput("pr_sha", source.head_sha);
+ core.setOutput("pr_conclusion", source.conclusion ?? "unknown");
+ core.setOutput("baseline_artifact", baselineRun ? "true" : "false");
+ core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : "");
+ core.setOutput("baseline_sha", baselineRun?.head_sha ?? "");
+ core.setOutput(
+ "baseline_matches_base",
+ baselineRun?.head_sha === pull.base.sha ? "true" : "false",
+ );
+}
+
+async function upsertComment(github, context, pullNumber, body, options = {}) {
+ const { owner, repo } = context.repo;
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: pullNumber,
+ per_page: 100,
+ });
+ const existing = comments.find(
+ (comment) =>
+ comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER),
+ );
+ if (
+ options.preserveResultSha &&
+ existing?.body?.includes(resultShaMarker(options.preserveResultSha))
+ ) {
+ return;
+ }
+ if (existing) {
+ await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
+ } else {
+ await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body });
+ }
+}
+
+async function upsertCommentForCurrentHead(
+ github,
+ context,
+ core,
+ pullNumber,
+ expectedSha,
+ body,
+ options,
+) {
+ const { owner, repo } = context.repo;
+ const { data: pull } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: pullNumber,
+ });
+ if (pull.head.sha !== expectedSha) {
+ core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`);
+ return false;
+ }
+
+ await upsertComment(github, context, pullNumber, body, options);
+ return true;
+}
+
+async function publish({ github, context, core }) {
+ const pullNumber = Number(process.env.PR_NUMBER);
+ if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) {
+ throw new Error("PR_NUMBER is invalid");
+ }
+
+ const current = readResult(process.env.PR_RESULT_DIR);
+ const currentRun = {
+ sha: process.env.PR_SHA,
+ conclusion: process.env.PR_CONCLUSION,
+ url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`,
+ };
+ if (!current) {
+ await upsertCommentForCurrentHead(
+ github,
+ context,
+ core,
+ pullNumber,
+ currentRun.sha,
+ [
+ COMMENT_MARKER,
+ "## Thread transfer impact",
+ "",
+ `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`,
+ "",
+ "_This comment will update automatically after the next completed run._",
+ ].join("\n"),
+ { preserveResultSha: currentRun.sha },
+ );
+ return;
+ }
+
+ const baseline = readResult(process.env.BASELINE_RESULT_DIR);
+ const baselineRun = baseline
+ ? {
+ sha: process.env.BASELINE_SHA,
+ matchesBase: process.env.BASELINE_MATCHES_BASE === "true",
+ url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`,
+ }
+ : undefined;
+ const body = renderComment({ current, baseline, currentRun, baselineRun });
+ const published = await upsertCommentForCurrentHead(
+ github,
+ context,
+ core,
+ pullNumber,
+ currentRun.sha,
+ body,
+ );
+ if (published) {
+ core.info(`Updated thread transfer report on PR #${pullNumber}.`);
+ }
+}
+
+module.exports = {
+ publish,
+ readResult,
+ renderComment,
+ resolve,
+ upsertCommentForCurrentHead,
+ validateResult,
+};
diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs
new file mode 100644
index 000000000000..4935864e46f0
--- /dev/null
+++ b/.github/scripts/thread-transfer-report.test.cjs
@@ -0,0 +1,292 @@
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const {
+ renderComment,
+ resolve,
+ upsertCommentForCurrentHead,
+ validateResult,
+} = require("./thread-transfer-report.cjs");
+
+function result(overrides = {}) {
+ const observed = {
+ totalWireBytes: 2_200_000,
+ threadSnapshotWireBytes: 1_950_000,
+ threadSnapshotDecodedBytes: 9_100_000,
+ measuredTurnWebSocketWireBytes: 250_000,
+ measuredTurnWebSocketDecodedBytes: 1_150_000,
+ measuredTurnWebSocketMessages: 15,
+ };
+ const ceiling = {
+ totalWireBytes: 2_900_000,
+ threadSnapshotWireBytes: 2_600_000,
+ measuredTurnWebSocketWireBytes: 320_000,
+ measuredTurnWebSocketDecodedBytes: 1_550_000,
+ measuredTurnWebSocketMessages: 20,
+ };
+ return {
+ schemaVersion: 1,
+ scenario: {
+ id: "thread-transfer-v1",
+ historyTurns: 10,
+ historyCommandToolsPerTurn: 5,
+ historyMcpResultBytes: 900_000,
+ measuredCommandTools: 20,
+ measuredMcpResultBytes: 1_100_000,
+ },
+ providers: {
+ codex: { observed: { ...observed, ...overrides }, ceiling },
+ claudeAgent: { observed, ceiling },
+ },
+ };
+}
+
+test("validates the fixed artifact schema", () => {
+ assert.equal(validateResult(result()).schemaVersion, 1);
+ assert.throws(
+ () => validateResult({ ...result(), injectedMarkdown: "@everyone" }),
+ /unexpected fields/,
+ );
+ assert.throws(
+ () => validateResult(result({ totalWireBytes: "lots" })),
+ /non-negative safe integer/,
+ );
+});
+
+test("renders baseline, impact, ceiling, and ceiling changes", () => {
+ const baseline = result();
+ const current = result({ measuredTurnWebSocketWireBytes: 260_000 });
+ current.providers.codex.ceiling = {
+ ...current.providers.codex.ceiling,
+ measuredTurnWebSocketWireBytes: 330_000,
+ };
+ const comment = renderComment({
+ current,
+ baseline,
+ currentRun: {
+ sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ conclusion: "success",
+ url: "https://github.com/pingdotgg/t3code/actions/runs/2",
+ },
+ baselineRun: {
+ sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ matchesBase: true,
+ url: "https://github.com/pingdotgg/t3code/actions/runs/1",
+ },
+ });
+
+ assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/);
+ assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/);
+ assert.match(comment, /This PR changes transfer ceilings/);
+ assert.match(comment, /312\.5 KiB → 322\.3 KiB/);
+ assert.match(comment, //);
+ assert.match(
+ comment,
+ //,
+ );
+});
+
+test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => {
+ const outputs = {};
+ const listWorkflowRunArtifacts = () => {};
+ const listWorkflowRuns = () => {};
+ const listPullRequestsAssociatedWithCommit = () => {};
+ const github = {
+ paginate: async (method, input) => {
+ if (method === listPullRequestsAssociatedWithCommit) {
+ return [
+ {
+ number: 5350,
+ state: "open",
+ head: { sha: "head-sha", ref: "feature-branch", repo: null },
+ },
+ ];
+ }
+ if (method === listWorkflowRunArtifacts) {
+ return [
+ {
+ name: "thread-transfer-results",
+ expired: false,
+ runId: input.run_id,
+ },
+ ];
+ }
+ if (method === listWorkflowRuns) {
+ return [{ id: 1, head_sha: "base-sha" }];
+ }
+ throw new Error("unexpected pagination call");
+ },
+ rest: {
+ actions: { listWorkflowRunArtifacts, listWorkflowRuns },
+ pulls: {
+ get: async () => ({
+ data: {
+ head: { sha: "head-sha" },
+ base: { sha: "base-sha", ref: "main" },
+ },
+ }),
+ },
+ repos: { listPullRequestsAssociatedWithCommit },
+ },
+ };
+ await resolve({
+ github,
+ context: {
+ repo: { owner: "pingdotgg", repo: "t3code" },
+ payload: {
+ workflow_run: {
+ id: 2,
+ event: "pull_request",
+ workflow_id: 3,
+ head_sha: "head-sha",
+ head_branch: "feature-branch",
+ head_repository: { full_name: "pingdotgg/t3code" },
+ conclusion: "success",
+ pull_requests: [],
+ },
+ },
+ },
+ core: {
+ info: () => {},
+ setOutput: (key, value) => {
+ outputs[key] = value;
+ },
+ },
+ });
+
+ assert.equal(outputs.publish, "true");
+ assert.equal(outputs.pull_number, "5350");
+ assert.equal(outputs.pr_artifact, "true");
+ assert.equal(outputs.baseline_run_id, "1");
+ assert.equal(outputs.baseline_matches_base, "true");
+});
+
+test("does not guess when a fallback commit belongs to multiple PRs", async () => {
+ const outputs = {};
+ const listPullRequestsAssociatedWithCommit = () => {};
+ let fetchedPull = false;
+ await resolve({
+ github: {
+ paginate: async (method) => {
+ assert.equal(method, listPullRequestsAssociatedWithCommit);
+ return [5350, 5351].map((number) => ({
+ number,
+ state: "open",
+ head: {
+ sha: "head-sha",
+ ref: "feature-branch",
+ repo: { full_name: "pingdotgg/t3code" },
+ },
+ }));
+ },
+ rest: {
+ actions: {},
+ pulls: {
+ get: async () => {
+ fetchedPull = true;
+ },
+ },
+ repos: { listPullRequestsAssociatedWithCommit },
+ },
+ },
+ context: {
+ repo: { owner: "pingdotgg", repo: "t3code" },
+ payload: {
+ workflow_run: {
+ id: 2,
+ event: "pull_request",
+ workflow_id: 3,
+ head_sha: "head-sha",
+ head_branch: "feature-branch",
+ head_repository: { full_name: "pingdotgg/t3code" },
+ conclusion: "success",
+ pull_requests: [],
+ },
+ },
+ },
+ core: {
+ info: () => {},
+ setOutput: (key, value) => {
+ outputs[key] = value;
+ },
+ },
+ });
+
+ assert.equal(outputs.publish, "false");
+ assert.equal(fetchedPull, false);
+});
+
+test("does not publish a stale result after the PR head advances", async () => {
+ let listedComments = false;
+ const info = [];
+ const published = await upsertCommentForCurrentHead(
+ {
+ paginate: async () => {
+ listedComments = true;
+ return [];
+ },
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: () => {
+ throw new Error("must not create a stale comment");
+ },
+ updateComment: () => {
+ throw new Error("must not update a stale comment");
+ },
+ },
+ pulls: {
+ get: async () => ({ data: { head: { sha: "new-head-sha" } } }),
+ },
+ },
+ },
+ { repo: { owner: "pingdotgg", repo: "t3code" } },
+ { info: (message) => info.push(message) },
+ 5350,
+ "old-head-sha",
+ "stale body",
+ );
+
+ assert.equal(published, false);
+ assert.equal(listedComments, false);
+ assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]);
+});
+
+test("preserves a successful result when a same-SHA rerun has no artifact", async () => {
+ let updatedComment = false;
+ const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const published = await upsertCommentForCurrentHead(
+ {
+ paginate: async () => [
+ {
+ id: 1,
+ user: { login: "github-actions[bot]" },
+ body: `\n`,
+ },
+ ],
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: () => {
+ updatedComment = true;
+ },
+ updateComment: () => {
+ updatedComment = true;
+ },
+ },
+ pulls: {
+ get: async () => ({ data: { head: { sha } } }),
+ },
+ },
+ },
+ { repo: { owner: "pingdotgg", repo: "t3code" } },
+ { info: () => {} },
+ 5350,
+ sha,
+ "missing artifact warning",
+ { preserveResultSha: sha },
+ );
+
+ assert.equal(published, true);
+ assert.equal(updatedComment, false);
+});
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6637dff343af..40895f68c833 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -113,8 +113,32 @@ jobs:
run: vp run --filter @t3tools/desktop ensure:electron
- name: Test
+ env:
+ T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md
+ T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json
run: vp run --filter './apps/*' --filter './packages/*' --filter '!@t3tools/desktop' test
+ - name: Publish transfer budget report
+ if: always()
+ run: |
+ if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then
+ tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md"
+ else
+ echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Upload thread transfer result
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: thread-transfer-results
+ path: ${{ runner.temp }}/thread-transfer-result.json
+ if-no-files-found: ignore
+ retention-days: 30
+
+ - name: Test resource monitor
+ run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
+
# Non-blocking: Electron's binary cannot currently be installed reliably
# on GitHub-hosted runners (see the best-effort step above), so these
# would fail for environmental reasons. They still run and report;
@@ -179,9 +203,6 @@ jobs:
src/components/chat/CompactComposerControlsMenu.browser.tsx \
src/components/settings/SettingsPanels.browser.tsx
- - name: Test resource monitor
- run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
-
mobile_native_static_analysis:
name: Mobile Native Static Analysis
runs-on: macos-26
diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml
new file mode 100644
index 000000000000..23eec72923bd
--- /dev/null
+++ b/.github/workflows/thread-transfer-report.yml
@@ -0,0 +1,75 @@
+name: Thread Transfer Report
+
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+
+permissions:
+ actions: read
+ contents: read
+ pull-requests: write
+
+jobs:
+ publish:
+ name: Publish PR comment
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-24.04
+ concurrency:
+ group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }}
+ cancel-in-progress: true
+ steps:
+ # workflow_run has a write-capable token even for fork PRs. Only load the
+ # publisher from the trusted default branch and never execute PR code.
+ - name: Checkout trusted publisher
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ sparse-checkout: .github/scripts
+
+ - name: Test trusted publisher
+ run: node --test .github/scripts/thread-transfer-report.test.cjs
+
+ - id: resolve
+ name: Resolve PR and baseline artifacts
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const reporter = require("./.github/scripts/thread-transfer-report.cjs");
+ await reporter.resolve({ github, context, core });
+
+ - name: Download PR result
+ if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true'
+ uses: actions/download-artifact@v8
+ with:
+ name: thread-transfer-results
+ path: ${{ runner.temp }}/thread-transfer/pr
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ steps.resolve.outputs.pr_run_id }}
+
+ - name: Download main baseline
+ if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true'
+ uses: actions/download-artifact@v8
+ with:
+ name: thread-transfer-results
+ path: ${{ runner.temp }}/thread-transfer/main
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ steps.resolve.outputs.baseline_run_id }}
+
+ - name: Update thread transfer comment
+ if: steps.resolve.outputs.publish == 'true'
+ uses: actions/github-script@v8
+ env:
+ PR_NUMBER: ${{ steps.resolve.outputs.pull_number }}
+ PR_SHA: ${{ steps.resolve.outputs.pr_sha }}
+ PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }}
+ PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }}
+ PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr
+ BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }}
+ BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }}
+ BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }}
+ BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main
+ with:
+ script: |
+ const reporter = require("./.github/scripts/thread-transfer-report.cjs");
+ await reporter.publish({ github, context, core });
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 53ef74f21911..c1cb8588b5ea 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";
const clientSettings: ClientSettings = {
- autoOpenPlanSidebar: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
@@ -30,6 +29,7 @@ const clientSettings: ClientSettings = {
fontSizeTerminal: 12,
fontSmoothing: true,
glassOpacity: 80,
+ planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro
index 111482208cfa..5557f5fb6b19 100644
--- a/apps/marketing/src/pages/download.astro
+++ b/apps/marketing/src/pages/download.astro
@@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
- Looking for older versions? Check the
+ Looking for older versions? Check the{" "}
GitHub releases page ↗
diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts
index 6573c9e11879..ad5ef13b62d5 100644
--- a/apps/mobile/src/connection/environment-cache-store.ts
+++ b/apps/mobile/src/connection/environment-cache-store.ts
@@ -17,7 +17,10 @@ import * as Schema from "effect/Schema";
import * as MobileDatabase from "../persistence/mobile-database";
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
-const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
+// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
+// makes pre-pagination clients discard the record instead of decoding a
+// partial thread as complete (rollback safety).
+const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index 173d093d8495..b7cb28376815 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -21,6 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { useThemeColor } from "../../lib/useThemeColor";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
+import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";
@@ -42,6 +43,11 @@ interface CloudEnvironmentRowsProps {
* with connect switches, availability status, refresh, and loading/error
* states. Shared between the Settings environments screen and the T3 Connect
* onboarding sheet.
+ *
+ * Already-connected relay environments render even without cloud config or a
+ * signed-in account — they are registered on this device and must stay
+ * reachable and removable. Only discovery (the available list, refresh, and
+ * its errors) requires a signed-in session.
*/
export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
// Showcase captures run without a Clerk publishable key, so `ClerkProvider`
@@ -50,20 +56,33 @@ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
if (props.showcaseSignedIn !== undefined) {
return props.showcaseSignedIn ? : null;
}
+ // No cloud config means no `ClerkProvider` either, so `useAuth` would throw.
+ if (!hasCloudPublicConfig()) {
+ return ;
+ }
return ;
}
function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
- if (!isSignedIn) return null;
+ if (!isSignedIn) return ;
return ;
}
-function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
+function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
+ if (props.connectedCloudEnvironments.length === 0) return null;
+ return ;
+}
+
+function CloudEnvironmentRowsContent(
+ props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean },
+) {
const controller = useConnectionController();
const iconColor = useThemeColor("--color-icon");
- const availableCloudEnvironments =
- props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments;
+ const discoveryAvailable = props.discoveryAvailable ?? true;
+ const availableCloudEnvironments = discoveryAvailable
+ ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments)
+ : [];
const [expandedErrorId, setExpandedErrorId] = useState(null);
const hasCloudRows =
props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
@@ -89,25 +108,27 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
{showHeader ? (
T3 Connect
- {
- void controller.refreshRelayEnvironments();
- }}
- className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
- >
- {controller.relayDiscovery.isRefreshing ? (
-
- ) : (
-
- )}
-
+ {discoveryAvailable ? (
+ {
+ void controller.refreshRelayEnvironments();
+ }}
+ className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
+ >
+ {controller.relayDiscovery.isRefreshing ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
) : null}
@@ -152,7 +173,9 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
{/* Rendered alongside any connected rows — a failed discovery must not
hide behind an otherwise-healthy list. */}
- {controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? (
+ {discoveryAvailable &&
+ controller.relayDiscovery.error &&
+ !controller.relayDiscovery.isRefreshing ? (
Could not load T3 Connect environments
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
index 93b806f6487b..53bbe4806462 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
@@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
-import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
import { splitEnvironmentSections } from "../connection/environmentSections";
@@ -161,18 +160,19 @@ export function SettingsEnvironmentsRouteScreen() {
)}
- {hasCloudPublicConfig() || SHOWCASE_ENABLED ? (
-
- ) : null}
+ {/* Always mounted: already-connected relay environments must stay
+ visible (and removable) even when cloud config is missing or the
+ user is signed out — the component gates discovery itself. */}
+
);
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 5cb04290f66d..3d83c8375006 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -61,6 +61,8 @@ export interface ThreadDetailScreenProps {
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
+ /** Non-null when older turns exist beyond the loaded window. */
+ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
@@ -371,6 +373,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
skills={selectedProviderSkills}
+ loadEarlier={props.loadEarlier ?? null}
/>
) : (
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 8ad117c86351..28df94b529bc 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -164,6 +164,11 @@ export interface ThreadFeedProps {
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly skills?: ReadonlyArray;
+ /** Non-null when older turns exist beyond the loaded window. */
+ readonly loadEarlier?: {
+ readonly loading: boolean;
+ readonly onLoadEarlier: () => void;
+ } | null;
}
function MessageAttachmentImage(props: {
@@ -1330,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const [viewportHeight, setViewportHeight] = useState(0);
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
+ // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
+ // whenever the viewport drifts back inside its geometric threshold, which
+ // yanked users off history they were reading every time a stream chunk grew
+ // a row. Follow breaks when the user scrolls up and away, and re-arms only
+ // when the list actually returns to the end (or on send / thread switch).
+ const [endFollowEnabled, setEndFollowEnabled] = useState(true);
+ const endFollowEnabledRef = useRef(true);
+ // A "user scroll session" spans from drag start through the end of its
+ // momentum; only motion inside a session can break follow, so MVCP
+ // compensations and programmatic scrolls never strand a follower.
+ const userScrollSessionRef = useRef(false);
+ const setEndFollow = useCallback((enabled: boolean) => {
+ if (endFollowEnabledRef.current === enabled) {
+ return;
+ }
+ endFollowEnabledRef.current = enabled;
+ setEndFollowEnabled(enabled);
+ }, []);
const [interactionState, setInteractionState] = useState<{
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record;
@@ -1449,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
nearListEnd.value =
contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
+
+ // Latch bookkeeping. LegendList recomputes its inset-aware end distance
+ // before invoking this handler, so getState() is current. Returning to
+ // the end re-arms follow no matter who scrolled (the user, or our own
+ // scroll-to-end); moving away breaks it only during a user-initiated
+ // scroll session, so MVCP compensations and programmatic repositioning
+ // can never strand a follower.
+ const listState = props.listRef.current?.getState();
+ if (listState) {
+ if (listState.isWithinMaintainScrollAtEndThreshold) {
+ setEndFollow(true);
+ } else if (userScrollSessionRef.current) {
+ setEndFollow(false);
+ }
+ }
},
- [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],
+ [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow],
);
+ const handleScrollBeginDrag = useCallback(() => {
+ userScrollSessionRef.current = true;
+ }, []);
+ // The session must survive past finger-lift so momentum that carries the
+ // user away from the end still breaks follow; a drag released with no
+ // momentum ends its session at the release itself, otherwise at momentum
+ // end. Leaving a session open would let a later animated maintain-scroll
+ // read as user motion and break follow spuriously.
+ const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => {
+ const velocity = event.nativeEvent.velocity?.y ?? 0;
+ if (Math.abs(velocity) < 0.05) {
+ userScrollSessionRef.current = false;
+ }
+ }, []);
+ const handleMomentumScrollEnd = useCallback(() => {
+ userScrollSessionRef.current = false;
+ }, []);
// Gated variant of the 180ms feed layout slide. Instant while browsing
// history: maintainVisibleContentPosition compensates the scroll offset in
@@ -1491,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
reportHeaderMaterialVisibility(false);
}, [props.threadId, reportHeaderMaterialVisibility]);
+ // A thread switch opens pinned to the end; a send explicitly returns to the
+ // live edge (ThreadDetailScreen scrolls the new message into place). Both
+ // re-arm follow regardless of where the user had scrolled before.
+ useEffect(() => {
+ userScrollSessionRef.current = false;
+ setEndFollow(true);
+ }, [props.threadId, setEndFollow]);
+ useEffect(() => {
+ if (props.anchorMessageId !== null) {
+ userScrollSessionRef.current = false;
+ setEndFollow(true);
+ }
+ }, [props.anchorMessageId, setEndFollow]);
+
const expandedWorkGroupIds = useMemo(() => {
const ids = new Set();
for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
@@ -1842,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// anchor scrolls also lets it correct a scroll that landed on a
// stale end target once the anchor row finishes measuring.
maintainScrollAtEnd={
- disclosureToggleSettling
+ disclosureToggleSettling || !endFollowEnabled
? false
: {
animated: true,
@@ -1891,9 +1960,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
alignItemsAtEnd
initialScrollAtEnd
onScroll={handleScroll}
+ onScrollBeginDrag={handleScrollBeginDrag}
+ onScrollEndDrag={handleScrollEndDrag}
+ onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
ListHeaderComponent={
- usesNativeAutomaticInsets ? null :
+ <>
+ {usesNativeAutomaticInsets ? null : }
+ {props.loadEarlier != null ? (
+
+
+ {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"}
+
+
+ ) : null}
+ >
}
contentContainerStyle={{
paddingTop: 12,
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 7fb4740ddcef..d7754b7d78f7 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -8,6 +8,10 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import * as Option from "effect/Option";
import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
+import {
+ requestOlderThreadTurns,
+ threadHasOlderTurns,
+} from "@t3tools/client-runtime/state/threads";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -190,6 +194,20 @@ function ThreadRouteContent(
useThreadSelection();
const selectedThreadDetailState = props.selectedThreadDetailState;
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
+ // "Load earlier turns" header state for windowed (paginated) thread loads.
+ const loadEarlierTurns = useMemo(() => {
+ if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) {
+ return null;
+ }
+ return {
+ loading:
+ selectedThreadDetailState.page._tag === "Some" &&
+ selectedThreadDetailState.page.value.loadingOlder,
+ onLoadEarlier: () => {
+ requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id);
+ },
+ };
+ }, [selectedThread, selectedThreadDetailState]);
const { selectedThreadCwd } = useSelectedThreadWorktree();
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
@@ -766,6 +784,7 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
+ loadEarlier={loadEarlierTurns}
activeThreadBusy={composer.activeThreadBusy}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts
new file mode 100644
index 000000000000..75714d1519e2
--- /dev/null
+++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts
@@ -0,0 +1,177 @@
+// @effect-diagnostics nodeBuiltinImport:off - Measures the real Node HTTP and WebSocket transports.
+import * as NodeHttp from "node:http";
+import * as NodeZlib from "node:zlib";
+
+import * as NodeSocket from "@effect/platform-node/NodeSocket";
+import { WsRpcGroup } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
+import * as Socket from "effect/unstable/socket/Socket";
+
+export class TransferHttpRequestError extends Schema.TaggedErrorClass()(
+ "TransferHttpRequestError",
+ {
+ url: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {}
+
+export interface HttpTransferMeasurement {
+ readonly status: number;
+ readonly contentEncoding: string | null;
+ readonly encodedBody: Uint8Array;
+ readonly encodedBodyBytes: number;
+ readonly decodedBody: Uint8Array;
+ readonly decodedBodyBytes: number;
+ /** HTTP response bytes read from the socket, including status line and headers. */
+ readonly wireBytes: number;
+}
+
+export const measureHttpGet = Effect.fn("TransferBudget.measureHttpGet")(function* (input: {
+ readonly url: string;
+ readonly headers?: Readonly>;
+}) {
+ return yield* Effect.tryPromise({
+ try: () =>
+ new Promise((resolve, reject) => {
+ let socketBytesBeforeResponse = 0;
+ const request = NodeHttp.get(
+ input.url,
+ {
+ agent: false,
+ headers: {
+ "accept-encoding": "gzip",
+ connection: "close",
+ ...input.headers,
+ },
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
+ response.once("error", reject);
+ response.once("end", () => {
+ try {
+ const encodedBody = Buffer.concat(chunks);
+ const header = response.headers["content-encoding"];
+ const contentEncoding = Array.isArray(header)
+ ? (header[0] ?? null)
+ : (header ?? null);
+ const decodedBody =
+ contentEncoding === "gzip" ? NodeZlib.gunzipSync(encodedBody) : encodedBody;
+ resolve({
+ status: response.statusCode ?? 0,
+ contentEncoding,
+ encodedBody,
+ encodedBodyBytes: encodedBody.byteLength,
+ decodedBody,
+ decodedBodyBytes: decodedBody.byteLength,
+ wireBytes: Math.max(0, response.socket.bytesRead - socketBytesBeforeResponse),
+ });
+ } catch (cause) {
+ reject(cause);
+ }
+ });
+ },
+ );
+ request.once("socket", (socket) => {
+ socketBytesBeforeResponse = socket.bytesRead;
+ });
+ request.once("error", reject);
+ request.setTimeout(10_000, () => {
+ request.destroy(new Error(`Timed out reading ${input.url}`));
+ });
+ }),
+ catch: (cause) => new TransferHttpRequestError({ url: input.url, cause }),
+ });
+});
+
+export interface WebSocketTransferTotals {
+ readonly wireBytes: number;
+ readonly decodedBytes: number;
+ readonly messages: number;
+}
+
+export interface WebSocketTransferRecorder {
+ readonly connect: (
+ url: string,
+ protocols: string | string[] | undefined,
+ cookie: string,
+ ) => globalThis.WebSocket;
+ readonly totals: () => WebSocketTransferTotals;
+ readonly negotiatedExtensions: () => string;
+}
+
+interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket {
+ readonly _socket?: {
+ readonly bytesRead: number;
+ };
+}
+
+function rawDataBytes(data: NodeSocket.NodeWS.RawData): number {
+ if (Array.isArray(data)) {
+ return data.reduce((total, chunk) => total + chunk.byteLength, 0);
+ }
+ return data.byteLength;
+}
+
+export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder {
+ let socket: NodeWebSocketWithTransport | null = null;
+ let decodedBytes = 0;
+ let messages = 0;
+
+ return {
+ connect: (url, protocols, cookie) => {
+ const nextSocket = new NodeSocket.NodeWS.WebSocket(url, protocols, {
+ headers: { cookie },
+ perMessageDeflate: true,
+ }) as NodeWebSocketWithTransport;
+ socket = nextSocket;
+ nextSocket.on("message", (data) => {
+ const bytes = rawDataBytes(data);
+ decodedBytes += bytes;
+ messages += 1;
+ });
+ return nextSocket as unknown as globalThis.WebSocket;
+ },
+ totals: () => ({
+ wireBytes: socket?._socket?.bytesRead ?? 0,
+ decodedBytes,
+ messages,
+ }),
+ negotiatedExtensions: () => socket?.extensions ?? "",
+ };
+}
+
+export function transferDelta(
+ start: WebSocketTransferTotals,
+ end: WebSocketTransferTotals,
+): WebSocketTransferTotals {
+ return {
+ wireBytes: Math.max(0, end.wireBytes - start.wireBytes),
+ decodedBytes: Math.max(0, end.decodedBytes - start.decodedBytes),
+ messages: Math.max(0, end.messages - start.messages),
+ };
+}
+
+export function countingWsRpcProtocolLayer(input: {
+ readonly url: string;
+ readonly cookie: string;
+ readonly recorder: WebSocketTransferRecorder;
+}) {
+ const webSocketConstructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url, protocols) =>
+ input.recorder.connect(url, protocols, input.cookie),
+ );
+ return RpcClient.layerProtocolSocket().pipe(
+ Layer.provide(
+ Socket.layerWebSocket(input.url, { openTimeout: "10 seconds" }).pipe(
+ Layer.provide(webSocketConstructorLayer),
+ ),
+ ),
+ Layer.provide(RpcSerialization.layerJson),
+ );
+}
+
+export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup);
+export type CountingWsRpcClient = Effect.Success;
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index c3d5cb51e7b6..a192c2a3b8a3 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -52,10 +52,13 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts";
import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
+import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts";
+import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts";
import {
OrchestrationEngineService,
type OrchestrationEngineShape,
@@ -219,6 +222,8 @@ export interface OrchestrationIntegrationHarness {
timeoutMs?: number,
): Effect.Effect;
};
+ readonly drainProviderRuntime: Effect.Effect;
+ readonly drainCheckpointReactor: Effect.Effect;
readonly dispose: Effect.Effect;
}
@@ -307,7 +312,10 @@ export const makeOrchestrationIntegrationHarness = (
checkpointStoreLayer,
providerLayer,
RuntimeReceiptBusTest,
- ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
+ ).pipe(
+ Layer.provideMerge(ThreadBackgroundLiveness.layer),
+ Layer.provideMerge(ThreadPlanProgress.layer),
+ );
const serverSettingsLayer = ServerSettingsService.layerTest();
const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
@@ -393,6 +401,13 @@ export const makeOrchestrationIntegrationHarness = (
const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>
runtime.runPromise(Effect.service(OrchestrationReactor)),
).pipe(Effect.orDie);
+ const providerRuntimeIngestion = yield* tryRuntimePromise(
+ "load ProviderRuntimeIngestion service",
+ () => runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)),
+ ).pipe(Effect.orDie);
+ const checkpointReactor = yield* tryRuntimePromise("load CheckpointReactor service", () =>
+ runtime.runPromise(Effect.service(CheckpointReactor)),
+ ).pipe(Effect.orDie);
const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>
runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
).pipe(Effect.orDie);
@@ -594,6 +609,8 @@ export const makeOrchestrationIntegrationHarness = (
waitForDomainEvent,
waitForPendingApproval,
waitForReceipt,
+ drainProviderRuntime: providerRuntimeIngestion.drain,
+ drainCheckpointReactor: checkpointReactor.drain,
dispose,
} satisfies OrchestrationIntegrationHarness;
});
diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts
index 1bceab3f1d78..998db2fd9fcc 100644
--- a/apps/server/integration/TestProviderAdapter.integration.ts
+++ b/apps/server/integration/TestProviderAdapter.integration.ts
@@ -11,7 +11,6 @@ import {
ProviderDriverKind,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
-import * as Crypto from "effect/Crypto";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
@@ -226,9 +225,9 @@ function missingSessionEffect(
export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
Effect.gen(function* () {
const provider = options?.provider ?? ProviderDriverKind.make("codex");
- const crypto = yield* Crypto.Crypto;
const runtimeEvents = yield* Queue.unbounded();
let sessionCount = 0;
+ let eventCount = 0;
const sessions = new Map();
const queuedResponsesForNextSession: TestTurnResponse[] = [];
const interruptCallsBySession = new Map>();
@@ -242,18 +241,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
>();
const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
- const randomUUIDv4 = (threadId: ThreadId) =>
- crypto.randomUUIDv4.pipe(
- Effect.mapError(
- (cause) =>
- new ProviderAdapterValidationError({
- provider,
- operation: "crypto/randomUUIDv4",
- issue: `Failed to generate test runtime identifier for thread '${threadId}'.`,
- cause,
- }),
- ),
- );
+ const nextEventId = (threadId: ThreadId) => {
+ eventCount += 1;
+ return EventId.make(`test-provider:${provider}:${threadId}:${eventCount}`);
+ };
const startSession: ProviderAdapterShape["startSession"] = (input) =>
Effect.gen(function* () {
@@ -322,7 +313,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
for (const fixtureEvent of response.events) {
const rawEvent: Record = {
...(fixtureEvent as Record),
- eventId: yield* randomUUIDv4(input.threadId),
+ eventId: nextEventId(input.threadId),
provider,
sessionId: RuntimeSessionId.make(String(input.threadId)),
};
@@ -379,7 +370,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
if (deferredTurnCompletedEvents.length === 0) {
yield* emit({
type: "turn.completed",
- eventId: EventId.make(yield* randomUUIDv4(input.threadId)),
+ eventId: nextEventId(input.threadId),
provider,
createdAt: nowIso(),
threadId: state.snapshot.threadId,
diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts
new file mode 100644
index 000000000000..f773b5b8b844
--- /dev/null
+++ b/apps/server/integration/TransferBudgetReport.integration.ts
@@ -0,0 +1,212 @@
+import type { ProviderDriverKind } from "@t3tools/contracts";
+
+import type {
+ HttpTransferMeasurement,
+ WebSocketTransferTotals,
+} from "./NetworkTransferMeasurement.integration.ts";
+import {
+ TRANSFER_HISTORY_MCP_RESULT_BYTES,
+ TRANSFER_HISTORY_TOOLS_PER_TURN,
+ TRANSFER_HISTORY_TURN_COUNT,
+ TRANSFER_MEASURED_MCP_RESULT_BYTES,
+ TRANSFER_MEASURED_TOOLS,
+} from "./fixtures/transferBudget.ts";
+
+export interface TransferBudgetRun {
+ readonly provider: ProviderDriverKind;
+ readonly threadSnapshot: HttpTransferMeasurement;
+ readonly measuredTurnWebSocket: WebSocketTransferTotals;
+}
+
+interface ProviderTransferBudget {
+ readonly totalWireBytes: number;
+ readonly threadSnapshotWireBytes: number;
+ readonly measuredTurnWebSocketWireBytes: number;
+ readonly measuredTurnWebSocketDecodedBytes: number;
+ readonly measuredTurnWebSocketMessages: number;
+}
+
+// These caps leave roughly 30% headroom above the client projection of the
+// deterministic 9 MB retained-result fixture. Full MCP results stay in
+// persistence, so accidentally shipping them again exceeds these caps by
+// orders of magnitude. The CI report preserves exact values for review.
+const TRANSFER_BUDGET = {
+ totalWireBytes: 15_500,
+ threadSnapshotWireBytes: 7_500,
+ measuredTurnWebSocketWireBytes: 8_000,
+ measuredTurnWebSocketDecodedBytes: 68_000,
+ measuredTurnWebSocketMessages: 21,
+} satisfies ProviderTransferBudget;
+
+export const TRANSFER_BUDGETS: Readonly> = {
+ codex: TRANSFER_BUDGET,
+ claudeAgent: TRANSFER_BUDGET,
+};
+
+function totalWireBytes(run: TransferBudgetRun): number {
+ return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes;
+}
+
+function observedTransfer(run: TransferBudgetRun) {
+ return {
+ totalWireBytes: totalWireBytes(run),
+ threadSnapshotWireBytes: run.threadSnapshot.wireBytes,
+ threadSnapshotDecodedBytes: run.threadSnapshot.decodedBodyBytes,
+ measuredTurnWebSocketWireBytes: run.measuredTurnWebSocket.wireBytes,
+ measuredTurnWebSocketDecodedBytes: run.measuredTurnWebSocket.decodedBytes,
+ measuredTurnWebSocketMessages: run.measuredTurnWebSocket.messages,
+ };
+}
+
+/** Machine-readable input for the trusted PR comment publisher. */
+export function formatTransferBudgetResult(runs: ReadonlyArray): string {
+ const providers = Object.fromEntries(
+ runs.flatMap((run) => {
+ const ceiling = TRANSFER_BUDGETS[run.provider];
+ return ceiling ? [[run.provider, { observed: observedTransfer(run), ceiling }]] : [];
+ }),
+ );
+
+ return `${JSON.stringify(
+ {
+ schemaVersion: 1,
+ scenario: {
+ id: "thread-transfer-v1",
+ historyTurns: TRANSFER_HISTORY_TURN_COUNT,
+ historyCommandToolsPerTurn: TRANSFER_HISTORY_TOOLS_PER_TURN,
+ historyMcpResultBytes: TRANSFER_HISTORY_MCP_RESULT_BYTES,
+ measuredCommandTools: TRANSFER_MEASURED_TOOLS,
+ measuredMcpResultBytes: TRANSFER_MEASURED_MCP_RESULT_BYTES,
+ },
+ providers,
+ },
+ null,
+ 2,
+ )}\n`;
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1_024) return `${bytes} B`;
+ if (bytes >= 1_024 * 1_024) {
+ return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB (${bytes.toLocaleString("en-US")} B)`;
+ }
+ return `${(bytes / 1_024).toFixed(1)} KiB (${bytes.toLocaleString("en-US")} B)`;
+}
+
+function row(
+ provider: ProviderDriverKind,
+ phase: string,
+ metric: string,
+ observed: number,
+ maximum: number,
+ format: (value: number) => string = formatBytes,
+): string {
+ const status = observed <= maximum ? "PASS" : "FAIL";
+ return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`;
+}
+
+export function transferBudgetViolations(runs: ReadonlyArray): string[] {
+ const violations: string[] = [];
+ for (const run of runs) {
+ const budget = TRANSFER_BUDGETS[run.provider];
+ if (!budget) {
+ violations.push(`${run.provider}: no transfer budget is configured`);
+ continue;
+ }
+ const checks = [
+ ["total thread wire bytes", totalWireBytes(run), budget.totalWireBytes],
+ ["thread snapshot wire bytes", run.threadSnapshot.wireBytes, budget.threadSnapshotWireBytes],
+ [
+ "measured-turn WebSocket wire bytes",
+ run.measuredTurnWebSocket.wireBytes,
+ budget.measuredTurnWebSocketWireBytes,
+ ],
+ [
+ "measured-turn WebSocket decoded bytes",
+ run.measuredTurnWebSocket.decodedBytes,
+ budget.measuredTurnWebSocketDecodedBytes,
+ ],
+ [
+ "measured-turn WebSocket messages",
+ run.measuredTurnWebSocket.messages,
+ budget.measuredTurnWebSocketMessages,
+ ],
+ ] as const;
+ for (const [metric, observed, maximum] of checks) {
+ if (observed > maximum) {
+ violations.push(`${run.provider}: ${metric} was ${observed}, maximum ${maximum}`);
+ }
+ }
+ }
+ return violations;
+}
+
+export function formatTransferBudgetReport(runs: ReadonlyArray): string {
+ const lines = [
+ "# T3 Code thread transfer budget",
+ "",
+ "Wire values are thread data bytes read from local HTTP and WebSocket sockets. HTTP includes response headers; WebSocket measurement starts after the resumed thread subscription synchronizes. TCP/IP, TLS framing, and the WebSocket upgrade are excluded. WebSocket permessage-deflate is negotiated.",
+ `Scenario: ${TRANSFER_HISTORY_TURN_COUNT} historical turns with ${TRANSFER_HISTORY_TOOLS_PER_TURN} command tools and one retained ${formatBytes(TRANSFER_HISTORY_MCP_RESULT_BYTES)} MCP result each, followed by one measured turn with ${TRANSFER_MEASURED_TOOLS} command tools and a retained ${formatBytes(TRANSFER_MEASURED_MCP_RESULT_BYTES)} MCP result. Payload sizes are calibrated from heavy local Codex and Claude histories and contain no user data.`,
+ "",
+ "| Provider | Total thread wire | Budget | Result |",
+ "| --- | ---: | ---: | --- |",
+ ...runs.flatMap((run) => {
+ const budget = TRANSFER_BUDGETS[run.provider];
+ if (!budget) return [];
+ const observed = observedTransfer(run).totalWireBytes;
+ return [
+ `| ${run.provider} | ${formatBytes(observed)} | ${formatBytes(budget.totalWireBytes)} | ${observed <= budget.totalWireBytes ? "PASS" : "FAIL"} |`,
+ ];
+ }),
+ "",
+ "## Detailed measurements",
+ "",
+ "| Provider | Phase | Metric | Observed | Budget | Result |",
+ "| --- | --- | --- | ---: | ---: | --- |",
+ ];
+
+ for (const run of runs) {
+ const budget = TRANSFER_BUDGETS[run.provider];
+ if (!budget) continue;
+ lines.push(
+ row(
+ run.provider,
+ "thread snapshot",
+ "HTTP wire",
+ run.threadSnapshot.wireBytes,
+ budget.threadSnapshotWireBytes,
+ ),
+ row(
+ run.provider,
+ "measured turn",
+ "WebSocket wire",
+ run.measuredTurnWebSocket.wireBytes,
+ budget.measuredTurnWebSocketWireBytes,
+ ),
+ row(
+ run.provider,
+ "measured turn",
+ "WebSocket decoded",
+ run.measuredTurnWebSocket.decodedBytes,
+ budget.measuredTurnWebSocketDecodedBytes,
+ ),
+ row(
+ run.provider,
+ "measured turn",
+ "WebSocket messages",
+ run.measuredTurnWebSocket.messages,
+ budget.measuredTurnWebSocketMessages,
+ String,
+ ),
+ );
+ }
+
+ lines.push("", "## Compression diagnostics", "");
+ for (const run of runs) {
+ lines.push(
+ `- ${run.provider}: thread snapshot ${formatBytes(run.threadSnapshot.decodedBodyBytes)} decoded to ${formatBytes(run.threadSnapshot.encodedBodyBytes)} gzip.`,
+ );
+ }
+
+ return `${lines.join("\n")}\n`;
+}
diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts
new file mode 100644
index 000000000000..77dfbc1dd7fb
--- /dev/null
+++ b/apps/server/integration/TransferBudgetScenario.integration.ts
@@ -0,0 +1,128 @@
+import {
+ CommandId,
+ defaultInstanceIdForDriver,
+ DEFAULT_MODEL,
+ DEFAULT_MODEL_BY_PROVIDER,
+ DEFAULT_PROVIDER_INTERACTION_MODE,
+ MessageId,
+ ProjectId,
+ ProviderDriverKind,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+
+import type { TurnProcessingQuiescedReceipt } from "../src/orchestration/Services/RuntimeReceiptBus.ts";
+import type { OrchestrationIntegrationHarness } from "./OrchestrationEngineHarness.integration.ts";
+import {
+ expectedRecordedAssistantText,
+ makeRecordedTransferTurn,
+ TRANSFER_HISTORY_TURN_COUNT,
+} from "./fixtures/transferBudget.ts";
+
+export const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project");
+export const TRANSFER_THREAD_ID = ThreadId.make("transfer-budget-thread");
+export const TRANSFER_MEASURED_TURN_INDEX = TRANSFER_HISTORY_TURN_COUNT;
+
+export function transferModelSelection(provider: ProviderDriverKind) {
+ return {
+ instanceId: defaultInstanceIdForDriver(provider),
+ model: DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL,
+ };
+}
+
+function turnTimestamp(turnIndex: number): string {
+ return `2026-06-01T00:${String(turnIndex).padStart(2, "0")}:00.000Z`;
+}
+
+export const TRANSFER_MEASURED_TURN_CREATED_AT = turnTimestamp(TRANSFER_MEASURED_TURN_INDEX);
+
+const waitForTurnQuiesced = Effect.fn("TransferBudget.waitForTurnQuiesced")(function* (
+ harness: OrchestrationIntegrationHarness,
+ checkpointTurnCount: number,
+) {
+ const receipt = yield* harness.waitForReceipt(
+ (receipt): receipt is TurnProcessingQuiescedReceipt =>
+ receipt.type === "turn.processing.quiesced" &&
+ receipt.threadId === TRANSFER_THREAD_ID &&
+ receipt.checkpointTurnCount === checkpointTurnCount,
+ );
+ yield* harness.drainProviderRuntime;
+ yield* harness.drainCheckpointReactor;
+ return receipt;
+});
+
+export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory")(function* (
+ harness: OrchestrationIntegrationHarness,
+ provider: ProviderDriverKind,
+) {
+ if (!harness.adapterHarness) {
+ return yield* Effect.die(new Error("Transfer budget history requires the replay adapter."));
+ }
+
+ const modelSelection = transferModelSelection(provider);
+ yield* harness.engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make(`transfer:${provider}:project-create`),
+ projectId: TRANSFER_PROJECT_ID,
+ title: "Transfer Budget Project",
+ workspaceRoot: harness.workspaceDir,
+ defaultModelSelection: modelSelection,
+ createdAt: turnTimestamp(0),
+ });
+ yield* harness.engine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`transfer:${provider}:thread-create`),
+ threadId: TRANSFER_THREAD_ID,
+ projectId: TRANSFER_PROJECT_ID,
+ title: `${provider} transfer history`,
+ modelSelection,
+ runtimeMode: "approval-required",
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ branch: "main",
+ worktreePath: harness.workspaceDir,
+ createdAt: turnTimestamp(0),
+ });
+
+ for (let turnIndex = 0; turnIndex < TRANSFER_HISTORY_TURN_COUNT; turnIndex += 1) {
+ const response = makeRecordedTransferTurn(provider, turnIndex);
+ if (turnIndex === 0) {
+ yield* harness.adapterHarness.queueTurnResponseForNextSession(response);
+ } else {
+ yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response);
+ }
+
+ yield* harness.engine.dispatch({
+ type: "thread.turn.start",
+ commandId: CommandId.make(`transfer:${provider}:turn:${turnIndex + 1}`),
+ threadId: TRANSFER_THREAD_ID,
+ message: {
+ messageId: MessageId.make(`transfer-user-${turnIndex + 1}`),
+ role: "user",
+ text: `Inspect transfer behavior for historical turn ${turnIndex + 1}.`,
+ attachments: [],
+ },
+ modelSelection,
+ runtimeMode: "approval-required",
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ createdAt: turnTimestamp(turnIndex),
+ });
+ yield* waitForTurnQuiesced(harness, turnIndex + 1);
+ }
+});
+
+export const queueMeasuredTransferTurn = Effect.fn("TransferBudget.queueMeasuredTurn")(function* (
+ harness: OrchestrationIntegrationHarness,
+ provider: ProviderDriverKind,
+) {
+ if (!harness.adapterHarness) {
+ return yield* Effect.die(new Error("Transfer budget measurement requires the replay adapter."));
+ }
+ const response = makeRecordedTransferTurn(provider, TRANSFER_MEASURED_TURN_INDEX);
+ yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response);
+});
+
+export function expectedMeasuredAssistantText(provider: ProviderDriverKind): string {
+ return expectedRecordedAssistantText(provider, TRANSFER_MEASURED_TURN_INDEX);
+}
+
+export { TRANSFER_HISTORY_TURN_COUNT, waitForTurnQuiesced };
diff --git a/apps/server/integration/fixtures/transferBudget.ts b/apps/server/integration/fixtures/transferBudget.ts
new file mode 100644
index 000000000000..d3567d386b9e
--- /dev/null
+++ b/apps/server/integration/fixtures/transferBudget.ts
@@ -0,0 +1,372 @@
+import { EventId, ProviderDriverKind } from "@t3tools/contracts";
+
+import type {
+ FixtureProviderRuntimeEvent,
+ TestTurnResponse,
+} from "../TestProviderAdapter.integration.ts";
+
+const FIXTURE_THREAD_ID = "transfer-budget-thread";
+const FIXTURE_TURN_ID = "transfer-budget-turn";
+
+export const TRANSFER_HISTORY_TURN_COUNT = 10;
+export const TRANSFER_HISTORY_TOOLS_PER_TURN = 5;
+export const TRANSFER_MEASURED_TOOLS = 20;
+export const TRANSFER_HISTORY_MCP_RESULT_BYTES = 900_000;
+export const TRANSFER_MEASURED_MCP_RESULT_BYTES = 1_100_000;
+
+const sourceModules = [
+ "connection/session.ts",
+ "connection/supervisor.ts",
+ "rpc/client.ts",
+ "rpc/protocol.ts",
+ "state/threads.ts",
+ "state/threadReducer.ts",
+ "state/threadSnapshotHttp.ts",
+ "orchestration/http.ts",
+ "orchestration/Normalizer.ts",
+ "orchestration/ActivityPayloadProjection.ts",
+ "provider/ProviderService.ts",
+ "provider/ProviderRuntimeIngestion.ts",
+ "persistence/ProjectionSnapshotQuery.ts",
+ "persistence/OrchestrationEventStore.ts",
+ "checkpointing/CheckpointStore.ts",
+ "checkpointing/CheckpointDiffQuery.ts",
+ "server.ts",
+] as const;
+
+function fixtureTimestamp(turnIndex: number, eventIndex: number): string {
+ const minute = String(turnIndex).padStart(2, "0");
+ const second = String(Math.floor(eventIndex / 1_000)).padStart(2, "0");
+ const millisecond = String(eventIndex % 1_000).padStart(3, "0");
+ return `2026-06-01T00:${minute}:${second}.${millisecond}Z`;
+}
+
+function mix(value: number): number {
+ let mixed = value | 0;
+ mixed ^= mixed >>> 16;
+ mixed = Math.imul(mixed, 0x7feb352d);
+ mixed ^= mixed >>> 15;
+ mixed = Math.imul(mixed, 0x846ca68b);
+ mixed ^= mixed >>> 16;
+ return mixed >>> 0;
+}
+
+function digest(seed: number): string {
+ return [0, 1, 2, 3]
+ .map((offset) =>
+ mix(seed + offset * 0x9e3779b9)
+ .toString(16)
+ .padStart(8, "0"),
+ )
+ .join("");
+}
+
+/** Produces safe, deterministic output with enough entropy to exercise gzip. */
+function diagnosticOutput(input: {
+ readonly provider: ProviderDriverKind;
+ readonly turnIndex: number;
+ readonly toolIndex: number;
+ readonly targetBytes: number;
+}): string {
+ const chunks: string[] = [];
+ const providerSeed = input.provider === "codex" ? 0x43_4f_44_45 : 0x43_4c_41_55;
+ let length = 0;
+ let lineIndex = 0;
+
+ while (length < input.targetBytes) {
+ const modulePath = sourceModules[(input.toolIndex + lineIndex) % sourceModules.length];
+ const seed =
+ providerSeed + input.turnIndex * 100_003 + input.toolIndex * 10_007 + lineIndex * 101;
+ const line =
+ `${String(lineIndex + 1).padStart(6, "0")} ${modulePath} ` +
+ `operation=project-transfer-${input.turnIndex + 1}-${input.toolIndex + 1} ` +
+ `cursor=${mix(seed)} digest=${digest(seed)} status=completed\n`;
+ chunks.push(line);
+ length += line.length;
+ lineIndex += 1;
+ }
+
+ return chunks.join("").slice(0, input.targetBytes);
+}
+
+function assistantChunks(provider: ProviderDriverKind, turnIndex: number): ReadonlyArray {
+ const providerName = provider === "codex" ? "Codex" : "Claude";
+ const paragraphs: string[] = [
+ `I traced the ${providerName} request through the environment connection and orchestration layers. `,
+ ];
+ let paragraphIndex = 0;
+ while (paragraphs.join("").length < 4_096) {
+ const modulePath = sourceModules[paragraphIndex % sourceModules.length];
+ paragraphs.push(
+ `Pass ${paragraphIndex + 1} reviewed ${modulePath} for turn ${turnIndex + 1}. ` +
+ "The shell cursor stayed monotonic, the thread snapshot remained resumable, and the client received only incremental events. ",
+ );
+ paragraphIndex += 1;
+ }
+ const text = paragraphs.join("").slice(0, 4_096);
+ return Array.from({ length: Math.ceil(text.length / 256) }, (_, index) =>
+ text.slice(index * 256, (index + 1) * 256),
+ );
+}
+
+export function expectedRecordedAssistantText(
+ provider: ProviderDriverKind,
+ turnIndex: number,
+): string {
+ return assistantChunks(provider, turnIndex).join("");
+}
+
+function unifiedDiff(provider: ProviderDriverKind, turnIndex: number): string {
+ const lines = sourceModules
+ .slice(0, 8)
+ .flatMap((modulePath, index) => [
+ `diff --git a/${modulePath} b/${modulePath}`,
+ `--- a/${modulePath}`,
+ `+++ b/${modulePath}`,
+ `@@ -${index + 1},2 +${index + 1},3 @@`,
+ ` const provider = "${provider}";`,
+ `+const transferTurn = ${turnIndex + 1};`,
+ `+const transferSample = ${1_500 + index * 97};`,
+ ]);
+ return lines.join("\n");
+}
+
+function baseEvent(
+ provider: ProviderDriverKind,
+ turnIndex: number,
+ eventIndex: number,
+): Pick {
+ return {
+ eventId: EventId.make(`recorded:${provider}:${turnIndex}:${eventIndex}`),
+ provider,
+ createdAt: fixtureTimestamp(turnIndex, eventIndex),
+ threadId: FIXTURE_THREAD_ID,
+ };
+}
+
+/**
+ * Synthetic canonical events calibrated from heavy local Codex and Claude
+ * threads. Ten historical turns produce 9 MB of retained MCP results without
+ * committing user content. Command output is intentionally modest because the
+ * client projection strips it.
+ */
+export function makeRecordedTransferTurn(
+ provider: ProviderDriverKind,
+ turnIndex: number,
+): TestTurnResponse {
+ const measuredTurn = turnIndex >= TRANSFER_HISTORY_TURN_COUNT;
+ const toolCount = measuredTurn ? TRANSFER_MEASURED_TOOLS : TRANSFER_HISTORY_TOOLS_PER_TURN;
+ const turnId = `${FIXTURE_TURN_ID}-${turnIndex + 1}`;
+ const events: FixtureProviderRuntimeEvent[] = [];
+ let eventIndex = 0;
+
+ events.push({
+ type: "turn.started",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ payload: {
+ model: provider === "codex" ? "gpt-5.4" : "claude-opus-4-1",
+ effort: provider === "codex" ? "high" : "default",
+ },
+ });
+
+ for (let toolIndex = 0; toolIndex < toolCount; toolIndex += 1) {
+ const itemId = `tool-${turnIndex + 1}-${toolIndex + 1}`;
+ const command =
+ provider === "codex"
+ ? `vp test transfer-budget-${toolIndex + 1}`
+ : `review transfer budget ${toolIndex + 1}`;
+ events.push(
+ {
+ type: "item.started",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ itemId,
+ payload: {
+ itemType: "command_execution",
+ status: "inProgress",
+ title: `Inspect transfer path ${toolIndex + 1}`,
+ detail: "Inspecting the HTTP snapshot and WebSocket projection boundaries.",
+ data: {
+ threadId: FIXTURE_THREAD_ID,
+ turnId,
+ startedAtMs: turnIndex * 60_000 + eventIndex,
+ item: {
+ id: itemId,
+ type: "commandExecution",
+ command,
+ cwd: "/workspace/transfer-budget",
+ processId: String(toolIndex + 1),
+ status: "inProgress",
+ commandActions: [],
+ aggregatedOutput: "",
+ },
+ },
+ },
+ },
+ {
+ type: "item.completed",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ itemId,
+ payload: {
+ itemType: "command_execution",
+ status: "completed",
+ title: `Inspected transfer path ${toolIndex + 1}`,
+ detail: "Collected a deterministic multi-module transfer diagnostic.",
+ data: {
+ threadId: FIXTURE_THREAD_ID,
+ turnId,
+ completedAtMs: turnIndex * 60_000 + eventIndex,
+ item: {
+ id: itemId,
+ type: "commandExecution",
+ command,
+ cwd: "/workspace/transfer-budget",
+ processId: String(toolIndex + 1),
+ status: "completed",
+ commandActions: [],
+ aggregatedOutput: diagnosticOutput({
+ provider,
+ turnIndex,
+ toolIndex,
+ targetBytes: 1_000,
+ }),
+ exitCode: 0,
+ durationMs: 500 + toolIndex,
+ },
+ },
+ },
+ },
+ );
+ }
+
+ const mcpItemId = `mcp-${turnIndex + 1}`;
+ const mcpResultBytes = measuredTurn
+ ? TRANSFER_MEASURED_MCP_RESULT_BYTES
+ : TRANSFER_HISTORY_MCP_RESULT_BYTES;
+ events.push(
+ {
+ type: "item.started",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ itemId: mcpItemId,
+ payload: {
+ itemType: "mcp_tool_call",
+ status: "inProgress",
+ title: "fixture-history · inspect_transfer_log",
+ detail: "Reading a retained diagnostic result from the provider history.",
+ data: {
+ startedAtMs: turnIndex * 60_000 + eventIndex,
+ threadId: FIXTURE_THREAD_ID,
+ turnId,
+ item: {
+ type: "mcpToolCall",
+ id: mcpItemId,
+ server: "fixture-history",
+ tool: "inspect_transfer_log",
+ arguments: { turn: turnIndex + 1 },
+ status: "inProgress",
+ },
+ },
+ },
+ },
+ {
+ type: "item.completed",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ itemId: mcpItemId,
+ payload: {
+ itemType: "mcp_tool_call",
+ status: "completed",
+ title: "fixture-history · inspect_transfer_log",
+ detail: "Retained a deterministic diagnostic result in the thread history.",
+ data: {
+ completedAtMs: turnIndex * 60_000 + eventIndex,
+ threadId: FIXTURE_THREAD_ID,
+ turnId,
+ item: {
+ type: "mcpToolCall",
+ id: mcpItemId,
+ server: "fixture-history",
+ tool: "inspect_transfer_log",
+ arguments: { turn: turnIndex + 1 },
+ durationMs: 1_000 + turnIndex,
+ error: null,
+ result: {
+ content: [
+ {
+ type: "text",
+ text: diagnosticOutput({
+ provider,
+ turnIndex,
+ toolIndex: toolCount,
+ targetBytes: mcpResultBytes,
+ }),
+ },
+ ],
+ },
+ status: "completed",
+ },
+ },
+ },
+ },
+ );
+
+ const chunks = assistantChunks(provider, turnIndex);
+ for (const [contentIndex, delta] of chunks.entries()) {
+ events.push({
+ type: "content.delta",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ itemId: `assistant-${turnIndex + 1}`,
+ payload: {
+ streamKind: "assistant_text",
+ delta,
+ contentIndex,
+ },
+ });
+ }
+
+ events.push(
+ {
+ type: "thread.token-usage.updated",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ payload: {
+ usage: {
+ usedTokens: 18_000 + turnIndex * 1_900,
+ maxTokens: 200_000,
+ inputTokens: 15_000 + turnIndex * 1_700,
+ cachedInputTokens: 9_000 + turnIndex * 1_100,
+ outputTokens: 3_000 + turnIndex * 200,
+ toolUses: toolCount,
+ durationMs: 4_000 + turnIndex * 250,
+ },
+ },
+ },
+ {
+ type: "turn.diff.updated",
+ ...baseEvent(provider, turnIndex, eventIndex++),
+ turnId,
+ payload: {
+ unifiedDiff: unifiedDiff(provider, turnIndex),
+ },
+ },
+ {
+ type: "turn.completed",
+ ...baseEvent(provider, turnIndex, eventIndex),
+ turnId,
+ payload: {
+ state: "completed",
+ stopReason: "end_turn",
+ usage: {
+ inputTokens: 15_000 + turnIndex * 1_700,
+ outputTokens: 3_000 + turnIndex * 200,
+ },
+ },
+ },
+ );
+
+ return { events };
+}
diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts
index 100b9beadbad..da22794951fb 100644
--- a/apps/server/src/git/GitWorkflowService.ts
+++ b/apps/server/src/git/GitWorkflowService.ts
@@ -69,6 +69,10 @@ export class GitWorkflowService extends Context.Service<
readonly cwd: string;
readonly remoteName: string;
}) => Effect.Effect;
+ readonly remoteExists: (input: {
+ readonly cwd: string;
+ readonly remoteName: string;
+ }) => Effect.Effect;
readonly resolveRemoteTrackingCommit: (input: {
readonly cwd: string;
readonly refName: string;
@@ -303,6 +307,10 @@ export const make = Effect.gen(function* () {
ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
Effect.andThen(git.fetchRemote(input)),
),
+ remoteExists: (input) =>
+ ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe(
+ Effect.andThen(git.remoteExists(input)),
+ ),
resolveRemoteTrackingCommit: (input) =>
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
Effect.andThen(git.resolveRemoteTrackingCommit(input)),
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index 08527a4de9fb..8a57b6dc734c 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
@@ -296,6 +297,7 @@ describe("CheckpointReactor", () => {
const orchestrationLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -304,6 +306,7 @@ describe("CheckpointReactor", () => {
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
index a7bfd0e06823..939444acd9e3 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import {
OrchestrationProjectionPipeline,
@@ -57,6 +58,7 @@ async function createOrchestrationSystem() {
OrchestrationProjectionSnapshotQueryLive,
).pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(RepositoryIdentityResolver.layer),
@@ -819,6 +821,7 @@ describe("OrchestrationEngine", () => {
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -925,6 +928,7 @@ describe("OrchestrationEngine", () => {
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -1069,6 +1073,7 @@ describe("OrchestrationEngine", () => {
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 9c4caf4c97de..8e65295b1bad 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -32,6 +32,7 @@ import {
} from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
import { ServerConfig } from "../../config.ts";
@@ -1430,6 +1431,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
assert.deepEqual(settledRows, [
{ state: "completed", completedAt: "2026-01-01T00:01:00.000Z" },
]);
+
+ const threadRows = yield* sql<{ readonly latestTurnId: string | null }>`
+ SELECT latest_turn_id AS "latestTurnId"
+ FROM projection_threads
+ WHERE thread_id = ${threadId}
+ `;
+ assert.deepEqual(threadRows, [{ latestTurnId: turnId }]);
}),
);
@@ -2666,6 +2674,7 @@ const engineLayer = it.layer(
OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 67b672271e0b..e4e6875290d8 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -860,7 +860,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
- latestTurnId: event.payload.session.activeTurnId,
+ // activeTurnId describes current work; a terminal session must not erase history.
+ latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId,
updatedAt: event.occurredAt,
});
yield* refreshThreadShellSummary(event.payload.threadId);
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index 665f36faf3b7..4ee12af7c7c7 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -18,7 +18,9 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
+import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";
const asProjectId = (value: string): ProjectId => ProjectId.make(value);
const asTurnId = (value: string): TurnId => TurnId.make(value);
@@ -29,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
const projectionSnapshotLayer = it.layer(
OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
@@ -448,6 +451,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
hasPendingUserInput: false,
hasActionableProposedPlan: false,
backgroundLiveness: null,
+ planProgress: null,
},
]);
@@ -1831,6 +1835,7 @@ it.effect(
const resolveCalls: string[] = [];
const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provideMerge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
resolve: (cwd: string) =>
@@ -1919,3 +1924,407 @@ it.effect(
}).pipe(Effect.provide(layer));
},
);
+
+projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) => {
+ // A thread shaped like real fan-out usage: user turns interleaved with
+ // subagent turns (no user pending message), plus a turnless straggler user
+ // message and a turnless activity anchored between turns.
+ //
+ // row turn pending msg anchor (requested_at)
+ // 1 turn-1 user-msg-1 T00
+ // 2 turn-2 (subagent) T01
+ // 3 turn-3 (subagent) T02
+ // 4 turn-4 user-msg-4 T03
+ // 5 turn-5 user-msg-5 T04
+ //
+ // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id)
+ // and a turnless activity at T03.6 — both belong to the page containing T03+.
+ const seedFanOutThread = Effect.fnUntraced(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // Tests in this block share one in-memory database; reset before seeding.
+ yield* sql`DELETE FROM projection_projects`;
+ yield* sql`DELETE FROM projection_threads`;
+ yield* sql`DELETE FROM projection_turns`;
+ yield* sql`DELETE FROM projection_thread_messages`;
+ yield* sql`DELETE FROM projection_thread_activities`;
+ yield* sql`DELETE FROM projection_state`;
+
+ yield* sql`
+ INSERT INTO projection_projects (
+ project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at
+ )
+ VALUES ('project-w', 'Windowed', '/tmp/project-w', '[]',
+ '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', NULL)
+ `;
+ yield* sql`
+ INSERT INTO projection_threads (
+ thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
+ latest_turn_id, pending_approval_count, pending_user_input_count,
+ has_actionable_proposed_plan, created_at, updated_at, deleted_at
+ )
+ VALUES ('thread-w', 'project-w', 'Windowed thread',
+ '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default',
+ 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL)
+ `;
+
+ const turns: ReadonlyArray<{
+ turn: string;
+ pendingMessage: string | null;
+ at: string;
+ }> = [
+ { turn: "turn-1", pendingMessage: "user-msg-1", at: "2026-03-01T00:00:00.000Z" },
+ { turn: "turn-2", pendingMessage: null, at: "2026-03-01T00:01:00.000Z" },
+ { turn: "turn-3", pendingMessage: null, at: "2026-03-01T00:02:00.000Z" },
+ { turn: "turn-4", pendingMessage: "user-msg-4", at: "2026-03-01T00:03:00.000Z" },
+ { turn: "turn-5", pendingMessage: "user-msg-5", at: "2026-03-01T00:04:00.000Z" },
+ ];
+ for (const { turn, pendingMessage, at } of turns) {
+ yield* sql`
+ INSERT INTO projection_turns (
+ thread_id, turn_id, pending_message_id, state, requested_at, started_at, completed_at,
+ checkpoint_files_json
+ )
+ VALUES ('thread-w', ${turn}, ${pendingMessage}, 'completed', ${at}, ${at}, ${at}, '[]')
+ `;
+ if (pendingMessage !== null) {
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
+ )
+ VALUES (${pendingMessage}, 'thread-w', NULL, 'user', ${"prompt for " + turn}, 0, ${at}, ${at})
+ `;
+ }
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
+ )
+ VALUES (${turn + "-reply"}, 'thread-w', ${turn}, 'assistant', ${"reply from " + turn}, 0, ${at}, ${at})
+ `;
+ yield* sql`
+ INSERT INTO projection_thread_activities (
+ activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at
+ )
+ VALUES (${turn + "-activity"}, 'thread-w', ${turn}, 'tool', 'tool.completed',
+ 'ran tool', '{"ok":true}', ${at})
+ `;
+ }
+
+ // Straggler user message sent while turn-4 ran: turn_id NULL and not any
+ // turn's pending_message_id.
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
+ )
+ VALUES ('user-msg-straggler', 'thread-w', NULL, 'user', 'while you are at it',
+ 0, '2026-03-01T00:03:30.000Z', '2026-03-01T00:03:30.000Z')
+ `;
+ // Turnless activity in the same time range.
+ yield* sql`
+ INSERT INTO projection_thread_activities (
+ activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at
+ )
+ VALUES ('turnless-activity', 'thread-w', NULL, 'info', 'context-window.updated',
+ 'usage', '{"usedTokens":1}', '2026-03-01T00:03:36.000Z')
+ `;
+
+ for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) {
+ yield* sql`
+ INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
+ VALUES (${projector}, 42, '2026-03-01T00:00:10.000Z')
+ `;
+ }
+ });
+
+ const threadW = ThreadId.make("thread-w");
+ const messageIds = (snapshot: { thread: { messages: ReadonlyArray<{ id: string }> } }) =>
+ snapshot.thread.messages.map((message) => message.id).toSorted();
+ const activityIds = (snapshot: { thread: { activities: ReadonlyArray<{ id: string }> } }) =>
+ snapshot.thread.activities.map((activity) => activity.id).toSorted();
+
+ it.effect("returns the full thread with no page metadata when no window is requested", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW);
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.equal(snapshot.value.page, undefined);
+ assert.equal(snapshot.value.thread.messages.length, 9);
+ assert.equal(snapshot.value.thread.activities.length, 6);
+ assert.equal(snapshot.value.snapshotSequence, 42);
+ }
+ }),
+ );
+
+ it.effect("windows to the last N user-anchored turns with subagent turns riding along", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ // turnLimit 2 walks back: turn-5 (user), turn-4 (user) -> window is
+ // rows 4..5. Subagent turns 2-3 are older than the 2nd user turn and
+ // stay out; the straggler message and turnless activity (T03.5/T03.6,
+ // after turn-4's anchor) ride along.
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.deepEqual(messageIds(snapshot.value), [
+ "turn-4-reply",
+ "turn-5-reply",
+ "user-msg-4",
+ "user-msg-5",
+ "user-msg-straggler",
+ ]);
+ assert.deepEqual(activityIds(snapshot.value), [
+ "turn-4-activity",
+ "turn-5-activity",
+ "turnless-activity",
+ ]);
+ assert.equal(snapshot.value.page?.hasMore, true);
+ assert.notEqual(snapshot.value.page?.beforeCursor, null);
+ assert.equal(snapshot.value.page?.snapshotSequence, 42);
+ }
+ }),
+ );
+
+ it.effect("subagent turns between user turns ride along inside the window", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ // turnLimit 3 reaches user turn-1, dragging subagent turns 2-3 along:
+ // the full thread, so no further pages.
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 3 });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.equal(snapshot.value.thread.messages.length, 9);
+ assert.equal(snapshot.value.thread.activities.length, 6);
+ assert.equal(snapshot.value.page?.hasMore, false);
+ assert.equal(snapshot.value.page?.beforeCursor, null);
+ }
+ }),
+ );
+
+ it.effect("cursors survive a projection rewrite that reassigns turn row ids", () =>
+ Effect.gen(function* () {
+ // The revert projector (and any projection rebuild) deletes and
+ // re-upserts projection_turns, assigning fresh autoincrement row ids.
+ // The keyset cursor is derived from event content, so a page cursor
+ // minted before the rewrite must keep working after it.
+ yield* seedFanOutThread();
+ const sql = yield* SqlClient.SqlClient;
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
+ assert.equal(firstPage._tag, "Some");
+ if (firstPage._tag !== "Some") return;
+ const cursor = firstPage.value.page?.beforeCursor;
+ assert.notEqual(cursor, null);
+ if (cursor === null || cursor === undefined) return;
+
+ // Simulate the rewrite: delete and re-insert every turn row with the
+ // same content, which reassigns all row ids.
+ const turnRows = yield* sql`
+ SELECT thread_id, turn_id, pending_message_id, state, requested_at, started_at,
+ completed_at, checkpoint_files_json
+ FROM projection_turns WHERE thread_id = 'thread-w' ORDER BY row_id
+ `;
+ yield* sql`DELETE FROM projection_turns WHERE thread_id = 'thread-w'`;
+ for (const row of turnRows) {
+ yield* sql`
+ INSERT INTO projection_turns (
+ thread_id, turn_id, pending_message_id, state, requested_at, started_at,
+ completed_at, checkpoint_files_json
+ )
+ VALUES (${row.thread_id as string}, ${row.turn_id as string},
+ ${row.pending_message_id as string | null}, ${row.state as string},
+ ${row.requested_at as string}, ${row.started_at as string},
+ ${row.completed_at as string}, ${row.checkpoint_files_json as string})
+ `;
+ }
+
+ const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 1,
+ beforeCursor: cursor,
+ });
+ assert.equal(olderPage._tag, "Some");
+ if (olderPage._tag === "Some") {
+ // Identical older slice to what the pre-rewrite cursor would return.
+ assert.deepEqual(messageIds(olderPage.value), [
+ "turn-1-reply",
+ "turn-2-reply",
+ "turn-3-reply",
+ "user-msg-1",
+ ]);
+ assert.equal(olderPage.value.page?.hasMore, false);
+ }
+ }),
+ );
+
+ it.effect("beforeCursor returns the disjoint adjacent older slice", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
+ assert.equal(firstPage._tag, "Some");
+ if (firstPage._tag !== "Some") return;
+ const cursor = firstPage.value.page?.beforeCursor;
+ assert.notEqual(cursor, null);
+ assert.notEqual(cursor, undefined);
+ if (cursor === null || cursor === undefined) return;
+
+ // Older page: user turn-1 plus subagent turns 2-3 riding along. Disjoint
+ // from the first page: no turn-4/5 rows, no straggler.
+ const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 1,
+ beforeCursor: cursor,
+ });
+ assert.equal(olderPage._tag, "Some");
+ if (olderPage._tag === "Some") {
+ assert.deepEqual(messageIds(olderPage.value), [
+ "turn-1-reply",
+ "turn-2-reply",
+ "turn-3-reply",
+ "user-msg-1",
+ ]);
+ assert.deepEqual(activityIds(olderPage.value), [
+ "turn-1-activity",
+ "turn-2-activity",
+ "turn-3-activity",
+ ]);
+ assert.equal(olderPage.value.page?.hasMore, false);
+ assert.equal(olderPage.value.page?.beforeCursor, null);
+ }
+ }),
+ );
+
+ it.effect("a cursor for a different thread degrades to the first page", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
+ assert.equal(firstPage._tag, "Some");
+ if (firstPage._tag !== "Some") return;
+
+ const foreign = encodeThreadDetailPageCursor({
+ threadId: ThreadId.make("thread-other"),
+ beforeAnchorAt: "2026-03-01T00:01:00.000Z",
+ beforeTurnId: "turn-2",
+ });
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 2,
+ beforeCursor: foreign,
+ });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.deepEqual(messageIds(snapshot.value), messageIds(firstPage.value));
+ }
+ }),
+ );
+
+ it.effect("a malformed cursor degrades to the first page instead of failing", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 2,
+ beforeCursor: "not-a-cursor",
+ });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.equal(snapshot.value.page?.hasMore, true);
+ assert.equal(snapshot.value.thread.messages.length, 5);
+ }
+ }),
+ );
+
+ it.effect("windows never split below the raw-turn ceiling boundary contiguously", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread();
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ // Page repeatedly with turnLimit 1 and assert the union of all pages is
+ // exactly the full thread with no duplicates (disjointness + coverage).
+ const seenMessages: string[] = [];
+ const seenActivities: string[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < 10; page += 1) {
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 1,
+ ...(cursor !== undefined ? { beforeCursor: cursor } : {}),
+ });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag !== "Some") return;
+ seenMessages.push(...snapshot.value.thread.messages.map((message) => message.id));
+ seenActivities.push(...snapshot.value.thread.activities.map((activity) => activity.id));
+ const next = snapshot.value.page?.beforeCursor;
+ if (next === null || next === undefined) break;
+ cursor = next;
+ }
+ assert.equal(new Set(seenMessages).size, seenMessages.length);
+ assert.equal(new Set(seenActivities).size, seenActivities.length);
+ assert.equal(seenMessages.length, 9);
+ assert.equal(seenActivities.length, 6);
+ }),
+ );
+
+ it.effect("a thread with no turns returns its content unwindowed on the first page", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ yield* sql`DELETE FROM projection_projects`;
+ yield* sql`DELETE FROM projection_threads`;
+ yield* sql`DELETE FROM projection_turns`;
+ yield* sql`DELETE FROM projection_thread_messages`;
+ yield* sql`DELETE FROM projection_thread_activities`;
+ yield* sql`DELETE FROM projection_state`;
+
+ yield* sql`
+ INSERT INTO projection_projects (
+ project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at
+ )
+ VALUES ('project-e', 'Empty', '/tmp/project-e', '[]',
+ '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL)
+ `;
+ yield* sql`
+ INSERT INTO projection_threads (
+ thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
+ pending_approval_count, pending_user_input_count, has_actionable_proposed_plan,
+ created_at, updated_at, deleted_at
+ )
+ VALUES ('thread-e', 'project-e', 'Turnless thread',
+ '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default',
+ 0, 0, 0, '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL)
+ `;
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
+ )
+ VALUES ('pre-turn-msg', 'thread-e', NULL, 'user', 'first prompt', 0,
+ '2026-03-02T00:00:01.000Z', '2026-03-02T00:00:01.000Z')
+ `;
+ for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) {
+ yield* sql`
+ INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
+ VALUES (${projector}, 7, '2026-03-02T00:00:01.000Z')
+ `;
+ }
+
+ const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(ThreadId.make("thread-e"), {
+ turnLimit: 5,
+ });
+ assert.equal(snapshot._tag, "Some");
+ if (snapshot._tag === "Some") {
+ assert.deepEqual(messageIds(snapshot.value), ["pre-turn-msg"]);
+ assert.equal(snapshot.value.page?.hasMore, false);
+ assert.equal(snapshot.value.page?.beforeCursor, null);
+ }
+ }),
+ );
+});
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index 2c3accf1bb15..1c266438a541 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -44,6 +44,7 @@ import {
} from "../../persistence/Errors.ts";
import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
+import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
@@ -51,6 +52,10 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
+import {
+ decodeThreadDetailPageCursor,
+ encodeThreadDetailPageCursor,
+} from "../threadDetailCursor.ts";
import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
import {
@@ -130,6 +135,36 @@ const ProjectIdLookupInput = Schema.Struct({
const ThreadIdLookupInput = Schema.Struct({
threadId: ThreadId,
});
+// Windowed reads order turns by the stable keyset (anchor, turn key), where
+// anchor is requested_at and turn key is
+// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
+// revert projector's row-id rewrite and full projection rebuilds.
+const ThreadTurnWindowLookupInput = Schema.Struct({
+ threadId: ThreadId,
+ // Exclusive keyset upper bound. Sentinels "~"/"" mean unbounded ("~" sorts
+ // after every ISO timestamp).
+ beforeAnchorAt: Schema.String,
+ beforeTurnKey: Schema.String,
+ userTurnLimit: Schema.Number,
+ maxRawTurns: Schema.Number,
+});
+const ProjectionTurnWindowRowSchema = Schema.Struct({
+ // The turn's timeline anchor, used to bound rows that have no turn linkage
+ // (user messages and turnless activities) to the same page window.
+ anchorAt: Schema.String,
+ turnKey: Schema.String,
+});
+const ThreadTurnRangeLookupInput = Schema.Struct({
+ threadId: ThreadId,
+ // Turn-linked rows are bounded by the keyset range [min, before) over
+ // (anchor, turn key); turnless rows by the matching [minAnchorAt,
+ // beforeAnchorAt) time range. Unbounded ends use sentinels: "" for the
+ // lower bound, "~" (sorts after ISO dates) for the upper bound.
+ minAnchorAt: Schema.String,
+ minTurnKey: Schema.String,
+ beforeAnchorAt: Schema.String,
+ beforeTurnKey: Schema.String,
+});
const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema;
const ProjectionThreadIdLookupRowSchema = Schema.Struct({
threadId: ThreadId,
@@ -322,6 +357,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
const makeProjectionSnapshotQuery = Effect.gen(function* () {
const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
+ const threadPlanProgress = yield* ThreadPlanProgressService;
const sql = yield* SqlClient.SqlClient;
const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const repositoryIdentityResolutionConcurrency = 4;
@@ -1067,6 +1103,197 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});
+ // Resolves a page of recent turns for a windowed thread detail read. Walks
+ // back from the exclusive (beforeAnchorAt, beforeTurnKey) keyset boundary
+ // (sentinels "~"/"" mean unbounded, i.e. the first page) until it has seen
+ // `userTurnLimit` user-anchored turns — turns whose pending message is a
+ // user message; subagent/fan-out turns between them ride along — or hits the
+ // `maxRawTurns` ceiling that bounds pathological fan-out. The `candidates`
+ // CTE applies the keyset bound and LIMIT before the window functions run;
+ // its ORDER BY uses raw columns so the migration-037
+ // (thread_id, requested_at, turn_id) index serves both range and order with
+ // no temp B-tree — the scan is genuinely bounded by the LIMIT. (Raw
+ // turn_id DESC places NULLs exactly where COALESCE-to-'' would, below every
+ // real id.) The caller derives the continuation cursor from the oldest
+ // returned row.
+ // Highest thread-DETAIL event sequence for this thread that the projection
+ // has applied (bounded by the global snapshot sequence read in the same
+ // transaction). This is the thread-scoped watermark a windowed page carries
+ // so clients can defer merging until their live subscription has caught up;
+ // the global sequence is not waitable per-thread. The event_type filter
+ // must match ws.ts's isThreadDetailEvent exactly: the subscription only
+ // delivers these types, so a watermark counting any other event could
+ // never be reached by the client and would park the page forever. Served
+ // by the event store's (aggregate_kind, stream_id, sequence) index.
+ const getThreadEventWatermarkRow = SqlSchema.findOneOption({
+ Request: Schema.Struct({ threadId: ThreadId, maxSequence: Schema.Number }),
+ Result: Schema.Struct({ threadSequence: Schema.NullOr(Schema.Number) }),
+ execute: ({ threadId, maxSequence }) =>
+ sql`
+ SELECT MAX(sequence) AS "threadSequence"
+ FROM orchestration_events
+ WHERE aggregate_kind = 'thread'
+ AND stream_id = ${threadId}
+ AND sequence <= ${maxSequence}
+ AND event_type IN (
+ 'thread.message-sent',
+ 'thread.proposed-plan-upserted',
+ 'thread.activity-appended',
+ 'thread.turn-diff-completed',
+ 'thread.reverted',
+ 'thread.session-set'
+ )
+ `,
+ });
+
+ const listTurnWindowRows = SqlSchema.findAll({
+ Request: ThreadTurnWindowLookupInput,
+ Result: ProjectionTurnWindowRowSchema,
+ execute: ({ threadId, beforeAnchorAt, beforeTurnKey, userTurnLimit, maxRawTurns }) =>
+ sql`
+ WITH candidates AS (
+ SELECT
+ turns.requested_at AS anchor_at,
+ COALESCE(turns.turn_id, '') AS turn_key,
+ turns.pending_message_id
+ FROM projection_turns AS turns
+ WHERE turns.thread_id = ${threadId}
+ AND (
+ turns.requested_at < ${beforeAnchorAt}
+ OR (
+ turns.requested_at = ${beforeAnchorAt}
+ AND COALESCE(turns.turn_id, '') < ${beforeTurnKey}
+ )
+ )
+ ORDER BY turns.requested_at DESC, turns.turn_id DESC
+ LIMIT ${maxRawTurns}
+ ),
+ walked AS (
+ SELECT
+ candidates.anchor_at,
+ candidates.turn_key,
+ CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END AS is_user_turn,
+ SUM(CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END) OVER (
+ ORDER BY candidates.anchor_at DESC, candidates.turn_key DESC
+ ) AS user_turns_seen
+ FROM candidates
+ LEFT JOIN projection_thread_messages AS messages
+ ON messages.message_id = candidates.pending_message_id
+ )
+ SELECT
+ anchor_at AS "anchorAt",
+ turn_key AS "turnKey"
+ FROM walked
+ WHERE user_turns_seen < ${userTurnLimit}
+ OR (user_turns_seen = ${userTurnLimit} AND is_user_turn = 1)
+ ORDER BY anchor_at ASC, turn_key ASC
+ `,
+ });
+
+ // Windowed variants of the two heavy collections. Turn-linked rows are
+ // bounded by the page's (anchor, turn key) keyset range over
+ // projection_turns; rows with no turn linkage (user messages always, and
+ // turnless activities like pre-turn context-window updates) are bounded by
+ // the matching turn-anchor time range so they land on the same page as the
+ // turns around them. Proposed plans and checkpoints stay unwindowed: they
+ // are metadata-scale.
+ const listThreadMessageRowsByThreadWindow = SqlSchema.findAll({
+ Request: ThreadTurnRangeLookupInput,
+ Result: ProjectionThreadMessageDbRowSchema,
+ execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) =>
+ sql`
+ SELECT
+ message_id AS "messageId",
+ thread_id AS "threadId",
+ turn_id AS "turnId",
+ role,
+ text,
+ attachments_json AS "attachments",
+ is_streaming AS "isStreaming",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"
+ FROM projection_thread_messages
+ WHERE thread_id = ${threadId}
+ AND (
+ turn_id IN (
+ SELECT turn_id FROM projection_turns
+ WHERE thread_id = ${threadId}
+ AND turn_id IS NOT NULL
+ AND (
+ requested_at > ${minAnchorAt}
+ OR (
+ requested_at = ${minAnchorAt}
+ AND turn_id >= ${minTurnKey}
+ )
+ )
+ AND (
+ requested_at < ${beforeAnchorAt}
+ OR (
+ requested_at = ${beforeAnchorAt}
+ AND turn_id < ${beforeTurnKey}
+ )
+ )
+ )
+ OR (
+ turn_id IS NULL
+ AND created_at >= ${minAnchorAt}
+ AND created_at < ${beforeAnchorAt}
+ )
+ )
+ ORDER BY created_at ASC, message_id ASC
+ `,
+ });
+
+ const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({
+ Request: ThreadTurnRangeLookupInput,
+ Result: ProjectionThreadActivityDbRowSchema,
+ execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) =>
+ sql`
+ SELECT
+ activity_id AS "activityId",
+ thread_id AS "threadId",
+ turn_id AS "turnId",
+ tone,
+ kind,
+ summary,
+ payload_json AS "payload",
+ sequence,
+ created_at AS "createdAt"
+ FROM projection_thread_activities
+ WHERE thread_id = ${threadId}
+ AND (
+ turn_id IN (
+ SELECT turn_id FROM projection_turns
+ WHERE thread_id = ${threadId}
+ AND turn_id IS NOT NULL
+ AND (
+ requested_at > ${minAnchorAt}
+ OR (
+ requested_at = ${minAnchorAt}
+ AND turn_id >= ${minTurnKey}
+ )
+ )
+ AND (
+ requested_at < ${beforeAnchorAt}
+ OR (
+ requested_at = ${beforeAnchorAt}
+ AND turn_id < ${beforeTurnKey}
+ )
+ )
+ )
+ OR (
+ turn_id IS NULL
+ AND created_at >= ${minAnchorAt}
+ AND created_at < ${beforeAnchorAt}
+ )
+ )
+ ORDER BY
+ sequence ASC,
+ created_at ASC,
+ activity_id ASC
+ `,
+ });
+
const getFullThreadDiffContextRow = SqlSchema.findOneOption({
Request: FullThreadDiffContextLookupInput,
Result: ProjectionFullThreadDiffContextRowSchema,
@@ -1710,6 +1937,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
row.threadId,
),
+ planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
} satisfies OrchestrationThreadShell)
: Result.failVoid,
),
@@ -1854,6 +2082,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
row.threadId,
),
+ planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
}),
),
updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
@@ -2130,10 +2359,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
threadRow.value.threadId,
),
+ planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId),
} satisfies OrchestrationThreadShell);
});
- const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) =>
+ // Contiguous turn range bounding a windowed detail read; undefined loads the
+ // full thread. Resolved from a window request inside the snapshot
+ // transaction (see getThreadDetailSnapshot).
+ interface ThreadDetailBounds {
+ readonly minAnchorAt: string;
+ readonly minTurnKey: string;
+ readonly beforeAnchorAt: string;
+ readonly beforeTurnKey: string;
+ }
+
+ const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) =>
Effect.gen(function* () {
const [
threadRow,
@@ -2152,7 +2392,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
),
),
),
- listThreadMessageRowsByThread({ threadId }).pipe(
+ (bounds === undefined
+ ? listThreadMessageRowsByThread({ threadId })
+ : listThreadMessageRowsByThreadWindow({ threadId, ...bounds })
+ ).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadDetailById:listMessages:query",
@@ -2168,7 +2411,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
),
),
),
- listThreadActivityRowsByThread({ threadId }).pipe(
+ (bounds === undefined
+ ? listThreadActivityRowsByThread({ threadId })
+ : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
+ ).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
@@ -2279,23 +2525,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
);
});
+ const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) =>
+ getThreadDetailByIdBounded(threadId, undefined);
+
+ // Bounds pathological fan-out: one user turn that spawned hundreds of
+ // subagent turns still pages in bounded chunks, at the cost of splitting the
+ // fan-out group across pages (the cursor continues the same group). Also
+ // structurally bounds the window scan via the candidates CTE's LIMIT.
+ const THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE = 150;
+ // Sentinels for unbounded keyset ends; "~" sorts after any ISO timestamp.
+ const ANCHOR_UNBOUNDED = "~";
+
const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = (
threadId,
+ window,
) =>
// Read the thread detail and the snapshot sequence within a single
// transaction so the sequence is consistent with the returned state; a
// projector update landing between two separate reads could otherwise return
// a sequence ahead of the thread detail, causing the client to resume from
- // too far and drop events.
+ // too far and drop events. Window resolution runs inside the same
+ // transaction so the page boundary is consistent with the returned rows.
sql
.withTransaction(
Effect.gen(function* () {
- const thread = yield* getThreadDetailById(threadId);
+ if (window?.turnLimit === undefined) {
+ const thread = yield* getThreadDetailById(threadId);
+ if (Option.isNone(thread)) {
+ return Option.none();
+ }
+ const { snapshotSequence } = yield* getSnapshotSequence();
+ return Option.some({ snapshotSequence, thread: thread.value });
+ }
+
+ // A malformed or foreign-thread cursor falls back to the first page
+ // rather than failing: the client's stale cursor after a revert or
+ // reconnect should degrade to "reload recent history", not error.
+ const decodedCursor =
+ window.beforeCursor === undefined
+ ? null
+ : decodeThreadDetailPageCursor(window.beforeCursor);
+ const cursor = decodedCursor?.threadId === threadId ? decodedCursor : null;
+
+ const windowRows = yield* listTurnWindowRows({
+ threadId,
+ beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,
+ beforeTurnKey: cursor?.beforeTurnId ?? "",
+ userTurnLimit: window.turnLimit,
+ maxRawTurns: THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE,
+ }).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:query",
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:decodeRows",
+ ),
+ ),
+ );
+
+ const oldest = windowRows[0];
+ // An empty window (no turns before the cursor, or a thread with no
+ // turns at all) still returns thread metadata with empty collections
+ // for turn-linked rows; turnless rows are bounded to the same empty
+ // range. The first page of a turnless thread stays unwindowed so
+ // pre-turn content (e.g. a just-created thread) is not hidden.
+ const bounds: ThreadDetailBounds | undefined =
+ oldest === undefined && cursor === null
+ ? undefined
+ : {
+ minAnchorAt: oldest?.anchorAt ?? "",
+ minTurnKey: oldest?.turnKey ?? "",
+ beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,
+ beforeTurnKey: cursor?.beforeTurnId ?? "",
+ };
+ // Empty window behind a cursor: nothing older remains.
+ const emptyBounds =
+ oldest === undefined && cursor !== null
+ ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }
+ : undefined;
+
+ const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds);
if (Option.isNone(thread)) {
return Option.none();
}
+
+ const hasMore =
+ oldest !== undefined &&
+ (yield* listTurnWindowRows({
+ threadId,
+ beforeAnchorAt: oldest.anchorAt,
+ beforeTurnKey: oldest.turnKey,
+ userTurnLimit: 1,
+ maxRawTurns: 1,
+ }).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query",
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows",
+ ),
+ ),
+ )).length > 0;
+
const { snapshotSequence } = yield* getSnapshotSequence();
- return Option.some({ snapshotSequence, thread: thread.value });
+ const watermarkRow = yield* getThreadEventWatermarkRow({
+ threadId,
+ maxSequence: snapshotSequence,
+ }).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:query",
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:decodeRow",
+ ),
+ ),
+ );
+ const threadSequence = Option.match(watermarkRow, {
+ onNone: () => 0,
+ onSome: (row) => row.threadSequence ?? 0,
+ });
+ return Option.some({
+ snapshotSequence,
+ thread: thread.value,
+ page: {
+ beforeCursor:
+ hasMore && oldest !== undefined
+ ? encodeThreadDetailPageCursor({
+ threadId,
+ beforeAnchorAt: oldest.anchorAt,
+ beforeTurnId: oldest.turnKey,
+ })
+ : null,
+ hasMore,
+ snapshotSequence,
+ threadSequence,
+ },
+ });
}),
)
.pipe(
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index 4545d48413ae..e98b01cc98cf 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import {
providerErrorLabel,
providerErrorLabelFromInstanceHint,
@@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => {
const orchestrationLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => {
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts
new file mode 100644
index 000000000000..936041038644
--- /dev/null
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts
@@ -0,0 +1,84 @@
+import {
+ EventId,
+ ProviderDriverKind,
+ RuntimeTaskId,
+ ThreadId,
+ type ProviderRuntimeEvent,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts";
+
+const base = {
+ provider: ProviderDriverKind.make("codex"),
+ createdAt: "2026-08-06T00:00:00.000Z",
+ threadId: ThreadId.make("thread-1"),
+};
+
+describe("runtimeEventToActivities task progress", () => {
+ it("persists usage independently from replaceable activity", () => {
+ const taskId = RuntimeTaskId.make("agent-1");
+ const usageOnly = {
+ ...base,
+ type: "task.progress",
+ eventId: EventId.make("evt-usage"),
+ payload: {
+ taskId,
+ description: "Agent one",
+ typedUsage: { totalTokens: 73_700_000 },
+ },
+ } satisfies ProviderRuntimeEvent;
+ const command = {
+ ...base,
+ type: "task.progress",
+ eventId: EventId.make("evt-command"),
+ payload: {
+ taskId,
+ description: "Agent one",
+ summary: "Running tests",
+ lastToolName: "exec_command",
+ },
+ } satisfies ProviderRuntimeEvent;
+
+ const usageActivities = runtimeEventToActivities(usageOnly);
+ const commandActivities = runtimeEventToActivities(command);
+
+ expect(usageActivities.map((activity) => activity.id)).toEqual(["task-usage:thread-1:agent-1"]);
+ expect(commandActivities.map((activity) => activity.id)).toEqual([
+ "task-progress:thread-1:agent-1",
+ ]);
+ const usagePayload = usageActivities[0]?.payload as Record | undefined;
+ expect(usagePayload?.typedUsage).toEqual({ totalTokens: 73_700_000 });
+ expect(usagePayload?.usageSnapshot).toBe(true);
+ });
+
+ it("splits combined progress and usage into their independent snapshots", () => {
+ const event = {
+ ...base,
+ type: "task.progress",
+ eventId: EventId.make("evt-combined"),
+ payload: {
+ taskId: RuntimeTaskId.make("agent-2"),
+ description: "Agent two",
+ summary: "Inspecting the panel",
+ typedUsage: { totalTokens: 4_200, toolUses: 7 },
+ status: "running",
+ },
+ } satisfies ProviderRuntimeEvent;
+
+ const activities = runtimeEventToActivities(event);
+ const progressPayload = activities[0]?.payload as Record;
+ const usagePayload = activities[1]?.payload as Record;
+
+ expect(activities.map((activity) => activity.id)).toEqual([
+ "task-progress:thread-1:agent-2",
+ "task-usage:thread-1:agent-2",
+ ]);
+ expect(progressPayload.summary).toBe("Inspecting the panel");
+ expect(progressPayload.status).toBe("running");
+ expect(progressPayload).not.toHaveProperty("typedUsage");
+ expect(usagePayload.typedUsage).toEqual({ totalTokens: 4_200, toolUses: 7 });
+ expect(usagePayload.usageSnapshot).toBe(true);
+ expect(usagePayload).not.toHaveProperty("status");
+ });
+});
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
index 31e30c4d1ada..dfc473207680 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
@@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
@@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => {
// Single shared liveness instance across ingestion (writer), the
// engine, and the snapshot query (reader).
Layer.provideMerge(ThreadBackgroundLiveness.layer),
+ Layer.provideMerge(ThreadPlanProgress.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index 29ac37947ce5..239aa4760261 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -36,6 +36,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio
import { isGitRepository } from "../../git/Utils.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
+import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import {
ProviderRuntimeIngestionService,
@@ -564,39 +565,80 @@ export function runtimeEventToActivities(
}
case "task.progress": {
+ const linkage = taskLinkageActivityFields(event.payload as Record);
+ // Usage and activity are independent latest-state streams. Keeping them
+ // under separate stable ids prevents a command/reasoning update from
+ // replacing the last known token count (and prevents a usage-only tick
+ // from blanking the last meaningful activity).
+ const identityLinkage = { ...linkage };
+ delete identityLinkage.typedUsage;
+ delete identityLinkage.status;
+ delete identityLinkage.error;
+ const title =
+ event.payload.description.trim().length > 0
+ ? { title: truncateDetail(event.payload.description, 120) }
+ : {};
+ const hasProgressState =
+ event.payload.typedUsage === undefined ||
+ event.payload.summary !== undefined ||
+ event.payload.lastToolName !== undefined ||
+ event.payload.status !== undefined ||
+ event.payload.error !== undefined;
return [
- {
- // Stable per-task id: progress is "latest state", not history, so
- // each tick REPLACES the last via the activity upsert (PK + the
- // replace-by-id apply in projector and client reducer). Keeps one
- // progress row per task instead of thousands, so a large fleet's
- // ticks can no longer evict its own start/terminal rows out of
- // the 500-row retention window. Thread-scoped: activity_id is a
- // GLOBAL primary key and Claude task ids are session-local, so a
- // bare taskId could collide across threads and steal another
- // thread's row (review finding).
- id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
- createdAt: event.createdAt,
- tone: "info",
- kind: "task.progress",
- summary:
- event.payload.description.trim().length > 0
- ? truncateDetail(event.payload.description, 120)
- : "Reasoning update",
- payload: {
- taskId: event.payload.taskId,
- ...(event.payload.description.trim().length > 0
- ? { title: truncateDetail(event.payload.description, 120) }
- : {}),
- detail: truncateDetail(event.payload.summary ?? event.payload.description),
- ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
- ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
- ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
- ...taskLinkageActivityFields(event.payload as Record),
- },
- turnId: toTurnId(event.turnId) ?? null,
- ...maybeSequence,
- },
+ ...(hasProgressState
+ ? [
+ {
+ // Stable per-task id: activity is "latest state", not
+ // history, so each meaningful tick replaces the last. This
+ // bounds a large fleet to one activity row per task.
+ id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
+ createdAt: event.createdAt,
+ tone: "info" as const,
+ kind: "task.progress" as const,
+ summary:
+ event.payload.description.trim().length > 0
+ ? truncateDetail(event.payload.description, 120)
+ : "Reasoning update",
+ payload: {
+ taskId: event.payload.taskId,
+ ...title,
+ detail: truncateDetail(event.payload.summary ?? event.payload.description),
+ ...(event.payload.summary
+ ? { summary: truncateDetail(event.payload.summary) }
+ : {}),
+ ...(event.payload.lastToolName
+ ? { lastToolName: event.payload.lastToolName }
+ : {}),
+ ...(event.payload.status ? { status: event.payload.status } : {}),
+ ...(event.payload.error ? { error: event.payload.error } : {}),
+ ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
+ ...identityLinkage,
+ },
+ turnId: toTurnId(event.turnId) ?? null,
+ ...maybeSequence,
+ },
+ ]
+ : []),
+ ...(event.payload.typedUsage !== undefined
+ ? [
+ {
+ id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`),
+ createdAt: event.createdAt,
+ tone: "info" as const,
+ kind: "task.progress" as const,
+ summary: "Task usage updated",
+ payload: {
+ taskId: event.payload.taskId,
+ ...title,
+ ...identityLinkage,
+ usageSnapshot: true,
+ typedUsage: event.payload.typedUsage,
+ },
+ turnId: toTurnId(event.turnId) ?? null,
+ ...maybeSequence,
+ },
+ ]
+ : []),
];
}
@@ -858,6 +900,7 @@ export function runtimeEventToActivities(
const make = Effect.gen(function* () {
const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
+ const threadPlanProgress = yield* ThreadPlanProgressService;
const crypto = yield* Crypto.Crypto;
const orchestrationEngine = yield* OrchestrationEngineService;
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
@@ -1926,6 +1969,21 @@ const make = Effect.gen(function* () {
yield* rememberTaskDescription(thread.id, event.payload.taskId, description);
}
}
+ // Working-indicator plan progress: current step while the turn runs,
+ // cleared on settle so a finished plan never lingers as stale UI.
+ // Events carrying a turn id that conflicts with the active turn are
+ // stale (superseded turn) and must neither overwrite nor clear the
+ // active turn's progress; session.exited always clears.
+ if (event.type === "session.exited") {
+ threadPlanProgress.clearThreadPlanProgress(thread.id);
+ } else if (!conflictsWithActiveTurn) {
+ if (event.type === "turn.plan.updated") {
+ threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan);
+ } else if (event.type === "turn.completed" || event.type === "turn.aborted") {
+ threadPlanProgress.clearThreadPlanProgress(thread.id);
+ }
+ }
+
// Sidebar background liveness: fed from the same lifecycle stream,
// read by the shell query at mapping time (no persistence).
switch (event.type) {
diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
index 64138fb75596..0a00253a2285 100644
--- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
@@ -17,6 +17,7 @@ import type {
OrchestrationShellSnapshot,
OrchestrationThread,
OrchestrationThreadDetailSnapshot,
+ OrchestrationThreadDetailWindow,
OrchestrationThreadShell,
ProjectId,
ThreadId,
@@ -174,9 +175,16 @@ export interface ProjectionSnapshotQueryShape {
* sequence in one consistent transaction, so the returned `snapshotSequence`
* exactly matches the state reflected in `thread` (no interleaving projector
* update between the two reads).
+ *
+ * When `window` is provided, the thread's messages, activities, proposed
+ * plans, and checkpoints are bounded to a page of recent turns and the
+ * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).
+ * Without a window the full thread is returned with no `page` field —
+ * pagination is strictly opt-in.
*/
readonly getThreadDetailSnapshot: (
threadId: ThreadId,
+ window?: OrchestrationThreadDetailWindow,
) => Effect.Effect, ProjectionRepositoryError>;
}
diff --git a/apps/server/src/orchestration/ThreadPlanProgress.test.ts b/apps/server/src/orchestration/ThreadPlanProgress.test.ts
new file mode 100644
index 000000000000..995453276729
--- /dev/null
+++ b/apps/server/src/orchestration/ThreadPlanProgress.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vite-plus/test";
+import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
+
+describe("ThreadPlanProgress", () => {
+ it("tracks the in-progress step and clears when the plan completes", () => {
+ const progress = ThreadPlanProgress.make();
+ const threadId = "t-plan-1";
+ progress.recordPlanProgress(threadId, [
+ { step: "Audit failure paths", status: "completed" },
+ { step: "Implement the fix", status: "inProgress" },
+ { step: "Run targeted tests", status: "pending" },
+ ]);
+ expect(progress.getThreadPlanProgress(threadId)).toEqual({
+ step: "Implement the fix",
+ completedSteps: 1,
+ totalSteps: 3,
+ });
+
+ progress.recordPlanProgress(threadId, [
+ { step: "Audit failure paths", status: "completed" },
+ { step: "Implement the fix", status: "completed" },
+ { step: "Run targeted tests", status: "completed" },
+ ]);
+ expect(progress.getThreadPlanProgress(threadId)).toBeNull();
+ });
+
+ it("falls back to the first non-completed step when nothing is in progress", () => {
+ const progress = ThreadPlanProgress.make();
+ const threadId = "t-plan-2";
+ progress.recordPlanProgress(threadId, [
+ { step: "First", status: "pending" },
+ { step: "Second", status: "pending" },
+ ]);
+ expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First");
+ });
+
+ it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => {
+ const progress = ThreadPlanProgress.make();
+ const threadId = "t-plan-3";
+ progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]);
+ progress.clearThreadPlanProgress(threadId);
+ expect(progress.getThreadPlanProgress(threadId)).toBeNull();
+ });
+});
diff --git a/apps/server/src/orchestration/ThreadPlanProgress.ts b/apps/server/src/orchestration/ThreadPlanProgress.ts
new file mode 100644
index 000000000000..1c638bf89a61
--- /dev/null
+++ b/apps/server/src/orchestration/ThreadPlanProgress.ts
@@ -0,0 +1,76 @@
+/**
+ * ThreadPlanProgressService - in-memory per-thread plan progress for the
+ * Working indicators (sidebar rows, in-chat working line).
+ *
+ * Plans are a progress annotation, not a surface of their own: the useful
+ * kernel of a turn.plan.updated event is "which step is the agent on right
+ * now". Ingestion records the current step here and the shell query reads it
+ * at mapping time — no persistence, no migration (same pattern as
+ * ThreadBackgroundLivenessService). Cleared when the turn settles or the
+ * session dies, so a finished plan never lingers as stale UI.
+ *
+ * @module ThreadPlanProgressService
+ */
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+
+export interface ThreadPlanProgress {
+ readonly step: string;
+ readonly completedSteps: number;
+ readonly totalSteps: number;
+}
+
+interface PlanStepInput {
+ readonly step: string;
+ readonly status: string;
+}
+
+export class ThreadPlanProgressService extends Context.Service<
+ ThreadPlanProgressService,
+ {
+ /**
+ * Feed one turn.plan.updated payload. An all-completed plan clears the
+ * entry (the turn is wrapping up; nothing is "in progress" anymore).
+ */
+ readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray) => void;
+
+ /** Turn settled or session died: the working indicator reverts to plain. */
+ readonly clearThreadPlanProgress: (threadId: string) => void;
+
+ readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null;
+ }
+>()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {}
+
+export function make(): ThreadPlanProgressService["Service"] {
+ const progressByThreadId = new Map();
+
+ return {
+ recordPlanProgress: (threadId, plan) => {
+ const totalSteps = plan.length;
+ const completedSteps = plan.filter((step) => step.status === "completed").length;
+ // Current step: the in-progress one, else the first pending one (a
+ // plan that was just written has no in-progress step yet).
+ const current =
+ plan.find((step) => step.status === "inProgress") ??
+ plan.find((step) => step.status !== "completed");
+ if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) {
+ progressByThreadId.delete(threadId);
+ return;
+ }
+ progressByThreadId.set(threadId, {
+ step: current.step,
+ completedSteps,
+ totalSteps,
+ });
+ },
+
+ clearThreadPlanProgress: (threadId) => {
+ progressByThreadId.delete(threadId);
+ },
+
+ getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null,
+ };
+}
+
+export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make));
diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts
index 9a5c8c0be39d..04d54ea8effb 100644
--- a/apps/server/src/orchestration/http.ts
+++ b/apps/server/src/orchestration/http.ts
@@ -66,7 +66,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationReadScope);
const snapshot = yield* projectionSnapshotQuery
- .getThreadDetailSnapshot(args.params.threadId)
+ .getThreadDetailSnapshot(
+ args.params.threadId,
+ args.payload.turnLimit === undefined
+ ? undefined
+ : {
+ turnLimit: args.payload.turnLimit,
+ ...(args.payload.beforeCursor !== undefined
+ ? { beforeCursor: args.payload.beforeCursor }
+ : {}),
+ },
+ )
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_thread_snapshot_failed", cause),
diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts
index 0bc624ec3656..779042e2f685 100644
--- a/apps/server/src/orchestration/runtimeLayer.ts
+++ b/apps/server/src/orchestration/runtimeLayer.ts
@@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
OrchestrationEventStoreLive,
@@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
OrchestrationProjectionSnapshotQueryLive,
OrchestrationEventInfrastructureLayerLive,
OrchestrationProjectionPipelineLayerLive,
- // Shared background-liveness registry: written by runtime ingestion,
- // read by the snapshot query. provideMerge feeds the same instance to
- // the snapshot query here and re-exports it for runtime ingestion.
-).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
+ // Shared background-liveness and plan-progress registries: written by
+ // runtime ingestion, read by the snapshot query. provideMerge feeds the
+ // same instance to the snapshot query here and re-exports it for runtime
+ // ingestion.
+).pipe(
+ Layer.provideMerge(ThreadBackgroundLiveness.layer),
+ Layer.provideMerge(ThreadPlanProgress.layer),
+);
export const OrchestrationLayerLive = Layer.mergeAll(
OrchestrationInfrastructureLayerLive,
diff --git a/apps/server/src/orchestration/threadDetailCursor.test.ts b/apps/server/src/orchestration/threadDetailCursor.test.ts
new file mode 100644
index 000000000000..434d83e86b18
--- /dev/null
+++ b/apps/server/src/orchestration/threadDetailCursor.test.ts
@@ -0,0 +1,44 @@
+import { ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+
+import {
+ decodeThreadDetailPageCursor,
+ encodeThreadDetailPageCursor,
+} from "./threadDetailCursor.ts";
+
+describe("threadDetailCursor", () => {
+ it("round-trips a cursor", () => {
+ const cursor = {
+ threadId: ThreadId.make("thread-1"),
+ beforeAnchorAt: "2026-08-01T00:00:00.000Z",
+ beforeTurnId: "turn-9",
+ };
+ expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor);
+ });
+
+ it("round-trips empty boundary values", () => {
+ // The anchor is COALESCE(requested_at, started_at, '') and the turn key
+ // is COALESCE(turn_id, ''), so a server-minted cursor can legitimately
+ // carry empty strings; rejecting them would degrade a valid cursor to a
+ // first-page request that repeats recent history (review finding).
+ const cursor = {
+ threadId: ThreadId.make("thread-1"),
+ beforeAnchorAt: "",
+ beforeTurnId: "",
+ };
+ expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor);
+ });
+
+ it("rejects malformed input", () => {
+ expect(decodeThreadDetailPageCursor("not-base64-json")).toBeNull();
+ expect(decodeThreadDetailPageCursor(Buffer.from("[]").toString("base64url"))).toBeNull();
+ expect(
+ decodeThreadDetailPageCursor(Buffer.from(JSON.stringify({ t: "" })).toString("base64url")),
+ ).toBeNull();
+ expect(
+ decodeThreadDetailPageCursor(
+ Buffer.from(JSON.stringify({ t: "thread-1", a: 5, i: "x" })).toString("base64url"),
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/apps/server/src/orchestration/threadDetailCursor.ts b/apps/server/src/orchestration/threadDetailCursor.ts
new file mode 100644
index 000000000000..a7dcf231ee60
--- /dev/null
+++ b/apps/server/src/orchestration/threadDetailCursor.ts
@@ -0,0 +1,62 @@
+import type { ThreadId } from "@t3tools/contracts";
+
+/**
+ * Opaque, exclusive cursor for windowed thread detail reads. Encodes the thread
+ * id and the keyset boundary of an already-delivered page: the boundary turn's
+ * anchor timestamp (`COALESCE(requested_at, started_at, '')`) and turn id.
+ * Passing it back requests the adjacent disjoint slice of strictly older turns
+ * under `(anchor, turn_id)` ordering.
+ *
+ * The boundary is deliberately NOT a `projection_turns.row_id`: row ids are
+ * rewritten by the revert projector (delete + re-upsert) and by projection
+ * rebuilds, which would silently invalidate every persisted cursor with no
+ * event emitted. The (anchor, turnId) pair is derived from event content, so
+ * cursors survive both and no client-side refresh machinery is needed. The
+ * anchor doubles as the time bound for rows with no turn linkage (straggler
+ * user messages, turnless activities). The thread id is embedded so a cursor
+ * can never be replayed against a different thread. Clients must treat the
+ * string as opaque.
+ */
+export interface ThreadDetailPageCursor {
+ readonly threadId: ThreadId;
+ readonly beforeAnchorAt: string;
+ /** Boundary turn id; "" for the rare turn row with a null turn_id. */
+ readonly beforeTurnId: string;
+}
+
+export function encodeThreadDetailPageCursor(cursor: ThreadDetailPageCursor): string {
+ return Buffer.from(
+ JSON.stringify({ t: cursor.threadId, a: cursor.beforeAnchorAt, i: cursor.beforeTurnId }),
+ ).toString("base64url");
+}
+
+/**
+ * Returns null for anything that is not a well-formed cursor. Callers degrade
+ * a malformed or foreign-thread cursor to a first-page request.
+ */
+export function decodeThreadDetailPageCursor(encoded: string): ThreadDetailPageCursor | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
+ } catch {
+ return null;
+ }
+ if (parsed === null || typeof parsed !== "object") {
+ return null;
+ }
+ const record = parsed as Record;
+ if (typeof record.t !== "string" || record.t.length === 0) {
+ return null;
+ }
+ // Empty strings are valid boundary values, not malformed input: the anchor
+ // is COALESCE(requested_at, started_at, ''), so a boundary turn with no
+ // timestamps encodes a: "" (and sorts before every real anchor, correctly
+ // ending the walk); the turn key is "" for a null turn_id.
+ if (typeof record.a !== "string") {
+ return null;
+ }
+ if (typeof record.i !== "string") {
+ return null;
+ }
+ return { threadId: record.t as ThreadId, beforeAnchorAt: record.a, beforeTurnId: record.i };
+}
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index d5a0f660a3b0..b9812dd1ea31 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -63,6 +63,9 @@ import Migration0040 from "./Migrations/037_RepairProjectionThreadTitleRegenerat
// Upstream ProjectionThreadsPinned (upstream file 036 / runtime 36) renumbered
// past fork titleRegenerationFailure filenames 036/037 and runtime ids 39/40.
import Migration0041 from "./Migrations/038_ProjectionThreadsPinned.ts";
+// Upstream ProjectionTurnsKeysetIndex (upstream file/runtime 037) renumbered
+// after the fork's title and pinned migration sequence.
+import Migration0042 from "./Migrations/039_ProjectionTurnsKeysetIndex.ts";
/**
* Migration loader with all migrations defined inline.
@@ -116,6 +119,7 @@ export const migrationEntries = [
[39, "ProjectionThreadTitleRegenerationFailure", Migration0039],
[40, "RepairProjectionThreadTitleRegenerationFailure", Migration0040],
[41, "ProjectionThreadsPinned", Migration0041],
+ [42, "ProjectionTurnsKeysetIndex", Migration0042],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/039_ProjectionTurnsKeysetIndex.ts b/apps/server/src/persistence/Migrations/039_ProjectionTurnsKeysetIndex.ts
new file mode 100644
index 000000000000..6b1ee7c03043
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/039_ProjectionTurnsKeysetIndex.ts
@@ -0,0 +1,17 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/**
+ * Composite index for windowed thread detail reads. Pagination orders turns by
+ * the stable keyset (requested_at, turn_id); the pre-existing
+ * (thread_id, requested_at) index cannot serve the tiebreak order, forcing a
+ * temp B-tree over all of a thread's turns before the page LIMIT applies.
+ * With this index the candidates scan is genuinely bounded by the page size.
+ */
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* sql`
+ CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_keyset
+ ON projection_turns(thread_id, requested_at, turn_id)
+ `;
+});
diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts
index 43ca40e9c7c8..1ab6166e92a1 100644
--- a/apps/server/src/process/externalLauncher.test.ts
+++ b/apps/server/src/process/externalLauncher.test.ts
@@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
@@ -155,6 +157,130 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+it.effect("memoizes editor discovery and refreshes after the cache window", () => {
+ let statCalls = 0;
+ const fileInfo = { type: "File" } as FileSystem.File.Info;
+ const launcherLayer = ExternalLauncher.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ FileSystem.layerNoop({
+ stat: () =>
+ Effect.sync(() => {
+ statCalls += 1;
+ return fileInfo;
+ }),
+ }),
+ Path.layer,
+ Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
+ ),
+ ),
+ ),
+ );
+
+ return Effect.gen(function* () {
+ const launcher = yield* ExternalLauncher.ExternalLauncher;
+
+ const first = yield* launcher.resolveAvailableEditors();
+ assert.equal(first.includes("vscode"), true);
+ const statCallsAfterFirstScan = statCalls;
+ assert.isAbove(statCallsAfterFirstScan, 0);
+
+ // Past the shared command-resolution cache TTL (30s) but within the
+ // discovery cache window: the memoized set is reused without any scan.
+ yield* TestClock.adjust("31 seconds");
+ const second = yield* launcher.resolveAvailableEditors();
+ assert.deepEqual([...second], [...first]);
+ assert.equal(statCalls, statCallsAfterFirstScan);
+
+ // Past the discovery cache window the next call rescans.
+ yield* TestClock.adjust("30 seconds");
+ yield* launcher.resolveAvailableEditors();
+ assert.isAbove(statCalls, statCallsAfterFirstScan);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ launcherLayer,
+ Layer.succeed(HostProcessPlatform, "win32"),
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ PATH: "C:\\t3-editor-discovery-cache-test",
+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
+ },
+ }),
+ ),
+ TestClock.layer(),
+ ),
+ ),
+ );
+});
+
+// A client that disconnects mid-scan interrupts the shared discovery effect on
+// the connection fiber. The cache must not retain that interrupt: doing so
+// replayed it to every later connect for the whole TTL, so `server.getConfig`
+// failed and no client could reconnect until the server restarted.
+it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => {
+ const fileInfo = { type: "File" } as FileSystem.File.Info;
+ let blockFirstScan = true;
+ let scans = 0;
+ const launcherLayer = ExternalLauncher.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ FileSystem.layerNoop({
+ // The first scan parks inside `stat` so the interrupt lands while
+ // discovery is in flight, which is what a client disconnecting
+ // mid-connect does to the shared effect.
+ stat: () =>
+ Effect.gen(function* () {
+ scans += 1;
+ if (blockFirstScan) {
+ return yield* Effect.never;
+ }
+ return fileInfo;
+ }),
+ }),
+ Path.layer,
+ Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
+ ),
+ ),
+ ),
+ );
+
+ return Effect.gen(function* () {
+ const launcher = yield* ExternalLauncher.ExternalLauncher;
+
+ const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors());
+ yield* Effect.yieldNow;
+ yield* Fiber.interrupt(fiber);
+
+ // The next connect must still get a real answer well inside the TTL.
+ blockFirstScan = false;
+ scans = 0;
+ const editors = yield* launcher.resolveAvailableEditors();
+ assert.equal(editors.includes("vscode"), true);
+ assert.isAbove(scans, 0);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ launcherLayer,
+ Layer.succeed(HostProcessPlatform, "win32"),
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ PATH: "C:\\t3-editor-discovery-interrupt-test",
+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
+ },
+ }),
+ ),
+ ),
+ ),
+ );
+});
+
it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts
index 9c2f0e417d3d..8ec928f26fc3 100644
--- a/apps/server/src/process/externalLauncher.ts
+++ b/apps/server/src/process/externalLauncher.ts
@@ -19,6 +19,7 @@ import {
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
+import * as Clock from "effect/Clock";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
@@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
+import * as Ref from "effect/Ref";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
@@ -298,6 +300,28 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
return yield* buildAvailableEditors(platform, env);
});
+// Editor discovery walks PATH for every known editor and runs for every
+// client connect (the server config embeds the available editors). Memoize
+// the discovered set for a bounded window so repeat connects skip even the
+// per-command cache lookups in @t3tools/shared/shell.
+//
+// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the
+// first caller's Exit whatever it is, including an interrupt. Callers run this
+// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`),
+// so one client disconnecting mid-scan would cache the interrupt and replay it
+// to every later connect for the whole TTL, breaking `server.getConfig`
+// permanently. Storing only on success means an interrupted scan leaves the
+// cache untouched and the next connect simply rescans.
+// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the
+// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock
+// adjustment cannot keep an expired entry alive.
+const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;
+
+interface EditorDiscoveryCacheEntry {
+ readonly editors: ReadonlyArray;
+ readonly expiresAtNanos: bigint;
+}
+
/**
* ExternalLauncher - Service tag for browser/editor launch operations.
*/
@@ -443,8 +467,28 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);
+ const editorDiscoveryCache = yield* Ref.make>(
+ Option.none(),
+ );
+ const cachedAvailableEditors = Effect.gen(function* () {
+ const nowNanos = yield* Clock.currentTimeNanos;
+ const entry = yield* Ref.get(editorDiscoveryCache);
+ if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) {
+ return entry.value.editors;
+ }
+ const editors = yield* provideCommandResolutionServices(resolveAvailableEditors());
+ yield* Ref.set(
+ editorDiscoveryCache,
+ Option.some({
+ editors,
+ expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS,
+ }),
+ );
+ return editors;
+ });
+
return ExternalLauncher.of({
- resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
+ resolveAvailableEditors: () => cachedAvailableEditors,
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
index d73582042abb..8a984310c688 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -1452,7 +1452,68 @@ describe("ClaudeAdapterLive", () => {
);
});
- it.effect("interruptTurn stops every live task before interrupting the turn", () => {
+ it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => {
+ const harness = makeHarness();
+ return Effect.gen(function* () {
+ const adapter = yield* ClaudeAdapter;
+
+ const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
+ Stream.runCollect,
+ Effect.forkChild,
+ );
+
+ const session = yield* adapter.startSession({
+ threadId: THREAD_ID,
+ provider: ProviderDriverKind.make("claudeAgent"),
+ runtimeMode: "full-access",
+ });
+
+ const turn = yield* adapter.sendTurn({
+ threadId: session.threadId,
+ input: "hello",
+ attachments: [],
+ });
+
+ // Exact shape the CLI emits when Stop lands mid-tool-call: is_error
+ // is true and the only error is internal diagnostic telemetry.
+ harness.query.emit({
+ type: "result",
+ subtype: "error_during_execution",
+ is_error: true,
+ errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],
+ stop_reason: "tool_use",
+ terminal_reason: "aborted_tools",
+ session_id: "sdk-session-abort-tools",
+ uuid: "result-abort-tools",
+ } as unknown as SDKMessage);
+
+ const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
+ assert.deepEqual(
+ runtimeEvents.map((event) => event.type),
+ [
+ "session.started",
+ "session.configured",
+ "session.state.changed",
+ "turn.started",
+ "thread.started",
+ "turn.completed",
+ ],
+ );
+
+ const turnCompleted = runtimeEvents[runtimeEvents.length - 1];
+ assert.equal(turnCompleted?.type, "turn.completed");
+ if (turnCompleted?.type === "turn.completed") {
+ assert.equal(String(turnCompleted.turnId), String(turn.turnId));
+ assert.equal(turnCompleted.payload.state, "interrupted");
+ assert.equal(turnCompleted.payload.errorMessage, undefined);
+ }
+ }).pipe(
+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
+ Effect.provide(harness.layer),
+ );
+ });
+
+ it.effect("interruptTurn settles every acknowledged live task before interrupting", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
@@ -1509,11 +1570,28 @@ describe("ClaudeAdapterLive", () => {
yield* Fiber.join(taskEventsFiber);
+ const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe(
+ Stream.filter((event) => event.type === "task.completed"),
+ Stream.take(1),
+ Stream.runCollect,
+ Effect.forkChild,
+ );
yield* adapter.interruptTurn(session.threadId);
// Only the still-live task is stopped; interrupt always fires after.
assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]);
assert.equal(harness.query.interruptCalls.length, 1);
+
+ const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber));
+ assert.equal(stoppedTaskEvents.length, 1);
+ const stoppedTaskEvent = stoppedTaskEvents[0];
+ assert.equal(stoppedTaskEvent?.type, "task.completed");
+ if (stoppedTaskEvent?.type === "task.completed") {
+ assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live");
+ assert.equal(stoppedTaskEvent.payload.status, "stopped");
+ assert.equal(stoppedTaskEvent.payload.taskType, "local_agent");
+ assert.equal(stoppedTaskEvent.payload.title, "Agent A");
+ }
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
@@ -2023,6 +2101,24 @@ describe("ClaudeAdapterLive", () => {
session_id: "session",
uuid: "roster",
},
+ {
+ type: "system",
+ subtype: "vcs_state_changed",
+ kind: "push",
+ cwd: "/tmp/worktree",
+ session_id: "session",
+ uuid: "vcs",
+ },
+ {
+ type: "system",
+ subtype: "code_change_published",
+ provider: "github",
+ url: "https://github.com/pingdotgg/t3code/pull/1",
+ repo: "pingdotgg/t3code",
+ identifier: "1",
+ session_id: "session",
+ uuid: "ccp",
+ },
{
type: "system",
subtype: "task_updated",
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
index 57d144ebb6a5..26e903c13506 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -65,6 +65,7 @@ import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
+import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
@@ -348,7 +349,29 @@ function resultErrorsText(result: SDKResultMessage): string {
: "";
}
+/**
+ * First user-facing error from a non-success result. "[ede_diagnostic] ..."
+ * entries are CLI-internal telemetry (the CLI hides them from its own UI too),
+ * so they must never become the error banner.
+ */
+function resultUserFacingError(result: SDKResultMessage): string | undefined {
+ if (result.subtype === "success" || !Array.isArray(result.errors)) {
+ return undefined;
+ }
+ return result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
+}
+
function isInterruptedResult(result: SDKResultMessage): boolean {
+ // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields
+ // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and
+ // is_error: true), interrupting mid-stream yields "aborted_streaming".
+ if (
+ result.terminal_reason === "aborted_tools" ||
+ result.terminal_reason === "aborted_streaming"
+ ) {
+ return true;
+ }
+
const errors = resultErrorsText(result);
if (errors.includes("interrupt")) {
return true;
@@ -2919,7 +2942,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}
const status = turnStatusFromResult(message);
- const errorMessage = message.subtype === "success" ? undefined : message.errors[0];
+ const errorMessage = resultUserFacingError(message);
if (status === "failed") {
yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
@@ -3034,9 +3057,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
// error rows in client work logs. `background_tasks_changed` is a roster
// snapshot ({tasks: [...]}) — the task_* lifecycle events carry the
// authoritative per-agent data and the typed background_tasks control
- // request is the reconciliation source.
- if ((message.subtype as string) === "background_tasks_changed") {
- return;
+ // request is the reconciliation source. `vcs_state_changed`
+ // ({kind: commit|push|rebase}) and `code_change_published`
+ // ({provider, url, repo}) are informational CLI notices; the work log
+ // already shows the underlying git/gh tool calls.
+ switch (message.subtype as string) {
+ case "background_tasks_changed":
+ case "vcs_state_changed":
+ case "code_change_published":
+ return;
}
switch (message.subtype) {
@@ -4395,11 +4424,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
yield* Effect.forEach(
liveIds,
(taskId) =>
- Effect.tryPromise({
- // Invoke through the query object: SDK methods rely on `this`.
- try: () => context.query.stopTask!(taskId),
- catch: () => undefined,
- }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore),
+ Effect.gen(function* () {
+ const stopAcknowledged = yield* Effect.tryPromise({
+ // Invoke through the query object: SDK methods rely on `this`.
+ try: () => context.query.stopTask!(taskId),
+ catch: () => undefined,
+ }).pipe(
+ Effect.timeoutOption("3 seconds"),
+ Effect.orElseSucceed(() => Option.none()),
+ );
+ if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) {
+ return;
+ }
+
+ // stopTask only acknowledges the control request. Its separate
+ // task_notification can lose the race with interrupt(), so make
+ // the acknowledged stop authoritative for the durable UI state.
+ const stamp = yield* makeEventStamp();
+ yield* offerRuntimeEvent({
+ type: "task.completed",
+ eventId: stamp.eventId,
+ provider: PROVIDER,
+ createdAt: stamp.createdAt,
+ threadId: context.session.threadId,
+ ...(context.turnState
+ ? { turnId: asCanonicalTurnId(context.turnState.turnId) }
+ : {}),
+ payload: {
+ taskId: RuntimeTaskId.make(taskId),
+ status: "stopped",
+ ...taskLinkageFor(context.taskAgents, taskId),
+ },
+ providerRefs: nativeProviderRefs(context),
+ });
+ }).pipe(Effect.ignore),
{ concurrency: 8, discard: true },
).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore);
}
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 70e3c239aa1f..6c0dc6c33a4a 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -2,7 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeCrypto from "node:crypto";
-import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
AuthAccessTokenType,
@@ -16,6 +16,8 @@ import {
KeybindingRule,
MessageId,
ExternalLauncherCommandNotFoundError,
+ OrchestrationThreadDetailSnapshot,
+ type OrchestrationThreadStreamItem,
type OrchestrationThreadShell,
TerminalNotRunningError,
type OrchestrationCommand,
@@ -41,6 +43,7 @@ import * as RelayClient from "@t3tools/shared/relayClient";
import { assert, it } from "@effect/vitest";
import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
import * as Clock from "effect/Clock";
+import * as Config from "effect/Config";
import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -52,6 +55,8 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";
@@ -70,11 +75,34 @@ import * as Socket from "effect/unstable/socket/Socket";
import { vi } from "vite-plus/test";
const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
+const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(OrchestrationThreadDetailSnapshot),
+);
+
+const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* (
+ queue: Queue.Queue,
+ predicate: (value: A) => boolean,
+ waitDescription: string,
+) {
+ return yield* Effect.gen(function* () {
+ const values: A[] = [];
+ while (true) {
+ const value = yield* Queue.take(queue);
+ values.push(value);
+ if (predicate(value)) return values;
+ }
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: "10 seconds",
+ orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)),
+ }),
+ );
+});
import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
import * as ServerConfig from "./config.ts";
import { makeRoutesLayer } from "./server.ts";
-import { resolveAvailableEditorsForConfig } from "./ws.ts";
+import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts";
import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
import * as GitManager from "./git/GitManager.ts";
import * as Keybindings from "./keybindings.ts";
@@ -123,6 +151,32 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts
import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import * as Data from "effect/Data";
+import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts";
+import {
+ countingWsRpcProtocolLayer,
+ makeCountingWsRpcClient,
+ makeWebSocketTransferRecorder,
+ measureHttpGet,
+ transferDelta,
+} from "../integration/NetworkTransferMeasurement.integration.ts";
+import {
+ expectedMeasuredAssistantText,
+ queueMeasuredTransferTurn,
+ seedTransferBudgetHistory,
+ TRANSFER_HISTORY_TURN_COUNT,
+ TRANSFER_MEASURED_TURN_CREATED_AT,
+ TRANSFER_MEASURED_TURN_INDEX,
+ TRANSFER_THREAD_ID,
+ transferModelSelection,
+ waitForTurnQuiesced,
+} from "../integration/TransferBudgetScenario.integration.ts";
+import {
+ formatTransferBudgetReport,
+ formatTransferBudgetResult,
+ type TransferBudgetRun,
+ transferBudgetViolations,
+} from "../integration/TransferBudgetReport.integration.ts";
+
const defaultProjectId = ProjectId.make("project-default");
const defaultThreadId = ThreadId.make("thread-default");
const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token";
@@ -549,9 +603,12 @@ const buildAppUnderTest = (options?: {
),
),
);
+ const serviceLauncherClientLayer = ServiceLauncherClient.layer.pipe(
+ Layer.provide(Layer.succeed(HostProcessEnvironment, {})),
+ );
const servedRoutesLayer = HttpRouter.serve(
- makeRoutesLayer.pipe(Layer.provide(ServiceLauncherClient.layer)),
+ makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)),
{
disableListenLog: true,
disableLogger: true,
@@ -1322,6 +1379,28 @@ const getWsServerUrl = (
);
});
+// Mirrors NodeHttpServer.layerTest, which does not expose server options,
+// with the production `websocket: { perMessageDeflate: true }` setting.
+const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(
+ Layer.provide(
+ Layer.fresh(FetchHttpClient.layer).pipe(
+ Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })),
+ ),
+ ),
+ Layer.provideMerge(
+ Layer.unwrap(
+ Effect.map(
+ Effect.promise(() => import("node:http")),
+ (NodeHttp) =>
+ NodeHttpServer.layer(NodeHttp.createServer, {
+ port: 0,
+ websocket: { perMessageDeflate: true },
+ }),
+ ),
+ ),
+ ),
+);
+
it.layer(NodeServices.layer)("server router seam", (it) => {
it.effect("parks HTTP ingress until command readiness", () =>
Effect.gen(function* () {
@@ -3222,28 +3301,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- // Mirrors NodeHttpServer.layerTest, which does not expose server options,
- // with the production `websocket: { perMessageDeflate: true }` setting.
- const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(
- Layer.provide(
- Layer.fresh(FetchHttpClient.layer).pipe(
- Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })),
- ),
- ),
- Layer.provideMerge(
- Layer.unwrap(
- Effect.map(
- Effect.promise(() => import("node:http")),
- (NodeHttp) =>
- NodeHttpServer.layer(NodeHttp.createServer, {
- port: 0,
- websocket: { perMessageDeflate: true },
- }),
- ),
- ),
- ),
- );
-
it.effect("negotiates permessage-deflate with clients that offer it", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
@@ -7140,6 +7197,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
pr: null,
}),
);
+ const remoteExists = vi.fn(
+ (_: Parameters[0]) =>
+ Effect.sync(() => {
+ bootstrapGitOperations.push("remote-exists");
+ return true;
+ }),
+ );
const fetchRemote = vi.fn(
(_: Parameters[0]) =>
Effect.sync(() => {
@@ -7187,6 +7251,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
yield* buildAppUnderTest({
layers: {
gitVcsDriver: {
+ remoteExists,
fetchRemote,
resolveRemoteTrackingCommit,
createWorktree,
@@ -7277,6 +7342,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
fallbackRemoteName: "origin",
});
assert.deepEqual(bootstrapGitOperations, [
+ "remote-exists",
"fetch",
"resolve-remote-commit",
"create-worktree",
@@ -7305,6 +7371,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect(
+ "falls back to the local base branch when startFromOrigin is set but no origin remote exists",
+ () =>
+ Effect.gen(function* () {
+ const dispatchedCommands: Array = [];
+ const remoteExists = vi.fn(
+ (_: Parameters[0]) =>
+ Effect.succeed(false),
+ );
+ const fetchRemote = vi.fn(
+ (_: Parameters[0]) => Effect.void,
+ );
+ const resolveRemoteTrackingCommit = vi.fn(
+ (_: Parameters[0]) =>
+ Effect.succeed({
+ commitSha: "0123456789abcdef0123456789abcdef01234567",
+ remoteRefName: "origin/main",
+ }),
+ );
+ const createWorktree = vi.fn(
+ (_: Parameters[0]) =>
+ Effect.succeed({
+ worktree: {
+ refName: "t3code/bootstrap-refName",
+ path: "/tmp/bootstrap-worktree",
+ },
+ }),
+ );
+
+ yield* buildAppUnderTest({
+ layers: {
+ gitVcsDriver: {
+ remoteExists,
+ fetchRemote,
+ resolveRemoteTrackingCommit,
+ createWorktree,
+ },
+ orchestrationEngine: {
+ dispatch: (command) =>
+ Effect.sync(() => {
+ dispatchedCommands.push(command);
+ return { sequence: dispatchedCommands.length };
+ }),
+ readEvents: () => Stream.empty,
+ },
+ },
+ });
+
+ const createdAt = "2026-01-01T00:00:00.000Z";
+ const wsUrl = yield* getWsServerUrl("/ws");
+ yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
+ type: "thread.turn.start",
+ commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"),
+ threadId: ThreadId.make("thread-bootstrap-no-origin"),
+ message: {
+ messageId: MessageId.make("msg-bootstrap-no-origin"),
+ role: "user",
+ text: "hello",
+ attachments: [],
+ },
+ modelSelection: defaultModelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ bootstrap: {
+ createThread: {
+ projectId: defaultProjectId,
+ title: "Bootstrap Thread",
+ modelSelection: defaultModelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: "main",
+ worktreePath: null,
+ createdAt,
+ },
+ prepareWorktree: {
+ projectCwd: "/tmp/project",
+ baseBranch: "main",
+ branch: "t3code/bootstrap-refName",
+ startFromOrigin: true,
+ },
+ },
+ createdAt,
+ }),
+ ),
+ );
+
+ assert.deepEqual(remoteExists.mock.calls[0]?.[0], {
+ cwd: "/tmp/project",
+ remoteName: "origin",
+ });
+ assert.equal(fetchRemote.mock.calls.length, 0);
+ assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0);
+ assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
+ cwd: "/tmp/project",
+ refName: "main",
+ newRefName: "t3code/bootstrap-refName",
+ baseRefName: "main",
+ path: null,
+ });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("records setup-script failures without aborting bootstrap turn start", () =>
Effect.gen(function* () {
const dispatchedCommands: Array = [];
@@ -7732,3 +7902,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
});
+
+it.live(
+ "reports thread HTTP and WebSocket transfer budgets",
+ () =>
+ Effect.gen(function* () {
+ const providers = [
+ ProviderDriverKind.make("codex"),
+ ProviderDriverKind.make("claudeAgent"),
+ ] as const;
+
+ const runs = yield* Effect.forEach(
+ providers,
+ (provider) =>
+ Effect.acquireUseRelease(
+ makeOrchestrationIntegrationHarness({ provider }),
+ (harness) =>
+ Effect.gen(function* () {
+ yield* seedTransferBudgetHistory(harness, provider);
+ yield* buildAppUnderTest({
+ layers: {
+ orchestrationEngine: harness.engine,
+ projectionSnapshotQuery: harness.snapshotQuery,
+ },
+ });
+
+ const baseUrl = yield* getHttpServerUrl();
+ const cookie = yield* getAuthenticatedSessionCookieHeader();
+
+ const recorder = makeWebSocketTransferRecorder();
+ const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws";
+ const protocolLayer = countingWsRpcProtocolLayer({
+ url: wsUrl,
+ cookie,
+ recorder,
+ });
+
+ return yield* Effect.scoped(
+ Effect.gen(function* () {
+ const client = yield* makeCountingWsRpcClient;
+
+ const threadSnapshot = yield* measureHttpGet({
+ url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`,
+ headers: { cookie },
+ });
+ assert.equal(threadSnapshot.status, 200);
+ assert.equal(threadSnapshot.contentEncoding, "gzip");
+ const decodedThread = yield* decodeTransferThreadSnapshot(
+ Buffer.from(threadSnapshot.decodedBody).toString("utf8"),
+ );
+ assert.equal(
+ decodedThread.thread.messages.length,
+ TRANSFER_HISTORY_TURN_COUNT * 2,
+ );
+
+ const threadItems = yield* Queue.unbounded();
+ yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({
+ threadId: TRANSFER_THREAD_ID,
+ afterSequence: decodedThread.snapshotSequence,
+ requestCompletionMarker: true,
+ }).pipe(
+ Stream.runForEach((item) =>
+ Queue.offer(threadItems, item).pipe(Effect.asVoid),
+ ),
+ Effect.forkScoped,
+ );
+ const initialThreadItems = yield* collectQueueUntil(
+ threadItems,
+ (item) => item.kind === "synchronized",
+ `${provider} thread subscription to synchronize`,
+ );
+ assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot"));
+ assert.include(recorder.negotiatedExtensions(), "permessage-deflate");
+
+ yield* queueMeasuredTransferTurn(harness, provider);
+ const turnStartTotals = recorder.totals();
+ yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
+ type: "thread.turn.start",
+ commandId: CommandId.make(`transfer:${provider}:measured-turn`),
+ threadId: TRANSFER_THREAD_ID,
+ message: {
+ messageId: MessageId.make("transfer-user-measured"),
+ role: "user",
+ text: "Measure the client-bound transfer for this turn.",
+ attachments: [],
+ },
+ modelSelection: transferModelSelection(provider),
+ runtimeMode: "approval-required",
+ interactionMode: "default",
+ createdAt: TRANSFER_MEASURED_TURN_CREATED_AT,
+ });
+ yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1);
+ const finalThreadSequence = yield* harness.engine
+ .readEvents(decodedThread.snapshotSequence, 10_000)
+ .pipe(
+ Stream.runFold(
+ () => decodedThread.snapshotSequence,
+ (sequence, event) =>
+ event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event)
+ ? Math.max(sequence, event.sequence)
+ : sequence,
+ ),
+ );
+ assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence);
+
+ yield* collectQueueUntil(
+ threadItems,
+ (item) =>
+ item.kind === "event" && item.event.sequence === finalThreadSequence,
+ `${provider} thread stream to reach sequence ${finalThreadSequence}`,
+ );
+ const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals());
+
+ const finalThreadSnapshot = yield* harness.snapshotQuery
+ .getThreadDetailSnapshot(TRANSFER_THREAD_ID)
+ .pipe(Effect.map(Option.getOrThrow));
+ const expectedAssistantText = expectedMeasuredAssistantText(provider);
+ const measuredAssistant = finalThreadSnapshot.thread.messages.find(
+ (message) =>
+ message.role === "assistant" && message.text === expectedAssistantText,
+ );
+ assert.isDefined(measuredAssistant);
+ assert.isTrue(
+ finalThreadSnapshot.thread.messages.length >= TRANSFER_HISTORY_TURN_COUNT * 2,
+ );
+ assert.equal(measuredAssistant?.streaming, false);
+ assert.equal(finalThreadSnapshot.thread.session?.status, "ready");
+ assert.equal(
+ finalThreadSnapshot.thread.checkpoints.length,
+ TRANSFER_HISTORY_TURN_COUNT + 1,
+ );
+
+ return {
+ provider,
+ threadSnapshot,
+ measuredTurnWebSocket,
+ } satisfies TransferBudgetRun;
+ }).pipe(Effect.provide(protocolLayer)),
+ );
+ }),
+ (harness) => harness.dispose,
+ ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)),
+ { concurrency: 1 },
+ );
+
+ const report = formatTransferBudgetReport(runs);
+ yield* Effect.logInfo(`\n${report}`);
+ const reportPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe(
+ Config.option,
+ );
+ if (Option.isSome(reportPath)) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ yield* fileSystem.writeFileString(reportPath.value, report);
+ }
+ const resultPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe(
+ Config.option,
+ );
+ if (Option.isSome(resultPath)) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ yield* fileSystem.writeFileString(resultPath.value, formatTransferBudgetResult(runs));
+ }
+ assert.deepEqual(transferBudgetViolations(runs), []);
+ }).pipe(Effect.provide(NodeServices.layer)),
+ 120_000,
+);
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index 192efe5a7d00..f256a7dd4e13 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -168,6 +168,11 @@ export interface GitFetchRemoteInput {
remoteName: string;
}
+export interface GitRemoteExistsInput {
+ cwd: string;
+ remoteName: string;
+}
+
export interface GitResolveRemoteTrackingCommitInput {
cwd: string;
refName: string;
@@ -243,6 +248,7 @@ export class GitVcsDriver extends Context.Service<
readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect;
readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect;
readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect;
+ readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect;
readonly resolveRemoteTrackingCommit: (
input: GitResolveRemoteTrackingCommitInput,
) => Effect.Effect;
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index ac49b5902d9e..033c42a4fbbd 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -1286,11 +1286,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
},
).pipe(Effect.map((result) => result.exitCode === 0));
- const originRemoteExists = (cwd: string): Effect.Effect =>
- executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
+ const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) =>
+ executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], {
allowNonZeroExit: true,
}).pipe(Effect.map((result) => result.exitCode === 0));
+ const originRemoteExists = (cwd: string): Effect.Effect =>
+ remoteExists({ cwd, remoteName: "origin" });
+
const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> =>
runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
Effect.map(parseRemoteNamesInGitOrder),
@@ -3073,6 +3076,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)),
resolvePrimaryRemoteName,
fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)),
+ remoteExists,
resolveRemoteTrackingCommit,
fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)),
fetchRemoteTrackingBranch: (input) =>
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index e54339068034..cdbd09784e94 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -268,7 +268,7 @@ function projectSetupScriptCompatibilityDetail(
}
}
-function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
+export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
OrchestrationEvent,
{
type:
@@ -908,7 +908,16 @@ const makeWsRpcLayer = (
if (bootstrap?.prepareWorktree) {
let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch;
- if (bootstrap.prepareWorktree.startFromOrigin) {
+ // "Start from origin" is a stored default; repos without an
+ // origin remote fall back to the local base branch instead of
+ // failing the whole bootstrap on `git fetch origin`.
+ const startFromOrigin =
+ bootstrap.prepareWorktree.startFromOrigin === true &&
+ (yield* gitWorkflow.remoteExists({
+ cwd: bootstrap.prepareWorktree.projectCwd,
+ remoteName: "origin",
+ }));
+ if (startFromOrigin) {
yield* gitWorkflow.fetchRemote({
cwd: bootstrap.prepareWorktree.projectCwd,
remoteName: "origin",
@@ -1010,6 +1019,7 @@ const makeWsRpcLayer = (
settings,
shellResumeCompletionMarker: true,
threadResumeCompletionMarker: true,
+ threadSnapshotPagination: true,
};
});
@@ -1356,7 +1366,14 @@ const makeWsRpcLayer = (
}
const snapshot = yield* projectionSnapshotQuery
- .getThreadDetailSnapshot(input.threadId)
+ .getThreadDetailSnapshot(
+ input.threadId,
+ // Windowing the fallback snapshot is opt-in per subscription:
+ // clients that don't send turnLimit (including all
+ // pre-pagination clients) get the full thread, since they
+ // have no way to load older pages.
+ input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit },
+ )
.pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx
index 169c662e585e..4eeff67ce5f7 100644
--- a/apps/web/src/components/AgentsPanel.tsx
+++ b/apps/web/src/components/AgentsPanel.tsx
@@ -4,13 +4,11 @@
* spawn batch).
*
* Visualization rules (from live-test feedback):
- * - Live work first: running workflows and direct spawns sort above settled.
- * - Rows are flat status lines — no expansion, no per-agent tool feeds. The
- * row answers "who / what phase / how much"; anything deeper is a future
- * drill-in, not an unfold.
- * - A settled workflow run collapses to a single summary line; click it to
- * show its member list inline (the one allowed toggle — run granularity,
- * not agent granularity).
+ * - Spawn order is stable. Activity and completion update rows in place.
+ * - Agent rows reserve three fixed lines for identity, activity, and metrics;
+ * changing data must never change their height.
+ * - Workflow expansion is presentation state. A live run stays expanded when
+ * it settles; older collapsed runs can still be opened at run granularity.
* - Static status dots, DOM-write elapsed timers, plain token counters.
*/
import { useAtomValue } from "@effect/atom-react";
@@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {
const visuals = STATUS_VISUALS[agent.status];
const activity = agentActivityText(agent);
const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);
+ const role =
+ agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase()
+ ? null
+ : agent.role;
+ const metadata = [
+ modelLabel,
+ agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok",
+ agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null,
+ agent.activationCount > 1 ? `run ${agent.activationCount}` : null,
+ ].filter((value): value is string => value !== null);
return (
-
-
-
-
-
-
-
- {agent.title}
- {agent.role ? (
-
- {agent.role}
-
- ) : null}
-
-
- {agent.status === "completed" ? (
-
- ) : null}
-
+
+
+
+
+
+ {agent.title}
+ {role ? (
+
+ {role}
- {activity ? (
-
- {activity}
-
+ ) : null}
+
+
+
+
+ {agent.status === "completed" ? (
+
) : null}
-
- {modelLabel ? {modelLabel} : null}
- {agent.usage ? (
-
- {modelLabel ? "· " : ""}
- {formatSubagentTokenCount(agent.usage.totalTokens)} tok
-
- ) : null}
- {agent.usage?.toolUses !== undefined ? (
- · {agent.usage.toolUses} tools
- ) : null}
- {agent.activationCount > 1 ? · run {agent.activationCount} : null}
- {visuals.label}
-
-
+
+
+ {activity ?? visuals.label}
+
+
+ {metadata.join(" · ")}
+
+ {visuals.label}
);
}
@@ -314,18 +308,32 @@ function WorkflowScriptView({
}
/**
- * Collapsible phase section (Claude Code Background-tasks pattern): live
- * phases open by default, done phases collapsed to header + member dot row.
- * User toggles override the default and stick for the phase's lifetime.
+ * Collapsible phase section. A phase opens when it becomes active, then keeps
+ * that shape as it settles so completion never yanks rows out from under the
+ * user. Manual toggles stick until a later activation begins.
*/
-function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) {
- const [userOpen, setUserOpen] = useState
(null);
- const open = userOpen ?? phase.state === "running";
+function PhaseSection({
+ phase,
+ defaultOpen = false,
+}: {
+ phase: AgentPanelWorkflowGroup["phases"][number];
+ defaultOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(defaultOpen || phase.state === "running");
+ const previousState = useRef(phase.state);
+
+ useEffect(() => {
+ if (previousState.current !== "running" && phase.state === "running") {
+ setOpen(true);
+ }
+ previousState.current = phase.state;
+ }, [phase.state]);
+
return (
- }
- />
-
- {children}
-
-
- );
-}
-
-function withoutProviderInstanceKey
(
- record: Readonly> | undefined,
- key: ProviderInstanceId,
-): Record {
- const next = { ...record } as Record;
- delete next[key];
- return next;
-}
-
-function withoutProviderInstanceFavorites(
- favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>,
- instanceId: ProviderInstanceId,
-) {
- return favorites.filter((favorite) => favorite.provider !== instanceId);
-}
-
-const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
- provider: definition.value,
-}));
-
-function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
- useRelativeTimeTick();
- const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
-
- if (lastCheckedRelative.status === "missing") {
- return null;
- }
-
- if (lastCheckedRelative.status === "invalid") {
- return Checked unavailable;
- }
-
- return (
-
- {lastCheckedRelative.suffix ? (
- <>
- Checked {lastCheckedRelative.value}{" "}
- {lastCheckedRelative.suffix}
- >
- ) : (
- <>Checked {lastCheckedRelative.value}>
- )}
-
- );
-}
-
function AboutVersionTitle() {
return (
@@ -623,9 +474,6 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
- ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
- ? ["Auto-open task panel"]
- : []),
...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
? ["Assistant output"]
: []),
@@ -655,7 +503,6 @@ export function useSettingsRestore(onRestored?: () => void) {
[
isTextGenerationModelDirty,
isBackgroundActivityDirty,
- settings.autoOpenPlanSidebar,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
@@ -701,7 +548,6 @@ export function useSettingsRestore(onRestored?: () => void) {
glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
- autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
@@ -1897,32 +1743,6 @@ export function GeneralSettingsPanel() {
}
/>
-
- updateSettings({
- autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
- })
- }
- />
- ) : null
- }
- control={
-
- updateSettings({ autoOpenPlanSidebar: Boolean(checked) })
- }
- aria-label="Open the task panel automatically"
- />
- }
- />
-
- >(() => new Set());
- const [openInstanceDetails, setOpenInstanceDetails] = useState>({});
- const refreshingRef = useRef(false);
-
- const providerUpdateCandidates = useMemo(
- () => collectProviderUpdateCandidates(serverProviders),
- [serverProviders],
- );
- const providerUpdateCandidateByInstanceId = useMemo(
- () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),
- [providerUpdateCandidates],
- );
- const visibleProviderSettings = PROVIDER_SETTINGS.filter(
- (providerSettings) =>
- providerSettings.provider !== "cursor" ||
- serverProviders.some(
- (provider) =>
- provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
- ),
- );
- const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
- const textGenInstanceId = textGenerationModelSelection.instanceId;
- const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
- const providerHealthPreset = getBackgroundActivityPresetSettings(
- resolvedBackgroundActivity.profile,
- ).providerHealthRefreshInterval;
- const providerHealthRefreshIntervalSeconds = durationToSeconds(
- resolvedBackgroundActivity.providerHealthRefreshInterval,
- );
- const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset);
- const lastCheckedAt =
- serverProviders.length > 0
- ? serverProviders.reduce(
- (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
- serverProviders[0]!.checkedAt,
- )
- : null;
-
- const refreshProviders = useCallback(() => {
- if (refreshingRef.current) return;
- refreshingRef.current = true;
- setIsRefreshingProviders(true);
- if (!primaryEnvironment) {
- refreshingRef.current = false;
- setIsRefreshingProviders(false);
- return;
- }
- void (async () => {
- const result = await refreshServerProviders({
- environmentId: primaryEnvironment.environmentId,
- input: {},
- });
- refreshingRef.current = false;
- setIsRefreshingProviders(false);
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- console.warn("Failed to refresh providers", {
- operation: "refresh-providers",
- environmentId: primaryEnvironment.environmentId,
- ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
- });
- }
- })();
- }, [primaryEnvironment, refreshServerProviders]);
-
- const runProviderUpdate = useCallback(
- async (candidate: ProviderUpdateCandidate) => {
- if (!primaryEnvironment) return;
- let started = false;
- setUpdatingProviderDrivers((previous) => {
- if (previous.has(candidate.driver)) {
- return previous;
- }
- started = true;
- const next = new Set(previous);
- next.add(candidate.driver);
- return next;
- });
- if (!started) {
- return;
- }
-
- const result = await updateProvider({
- environmentId: primaryEnvironment.environmentId,
- input: {
- provider: candidate.driver,
- instanceId: candidate.instanceId,
- },
- });
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`,
- description:
- error instanceof Error
- ? error.message
- : "The provider update command could not be started.",
- }),
- );
- }
- setUpdatingProviderDrivers((previous) => {
- if (!previous.has(candidate.driver)) {
- return previous;
- }
- const next = new Set(previous);
- next.delete(candidate.driver);
- return next;
- });
- },
- [primaryEnvironment, updateProvider],
- );
-
- interface InstanceRow {
- readonly instanceId: ProviderInstanceId;
- readonly instance: ProviderInstanceConfig;
- readonly driver: ProviderDriverKind;
- readonly isDefault: boolean;
- readonly isDirty?: boolean;
- }
-
- const instancesByDriver = new Map<
- ProviderDriverKind,
- Array<[ProviderInstanceId, ProviderInstanceConfig]>
- >();
- for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
- const driver = instance.driver;
- const list = instancesByDriver.get(driver) ?? [];
- list.push([rawId as ProviderInstanceId, instance]);
- instancesByDriver.set(driver, list);
- }
-
- const defaultSlotIdsBySource = new Set(
- visibleProviderSettings.map((providerSettings) =>
- String(defaultInstanceIdForDriver(providerSettings.provider)),
- ),
- );
-
- const rows: InstanceRow[] = [];
- const visibleDriverKinds = new Set(
- visibleProviderSettings.map((providerSettings) => providerSettings.provider),
- );
-
- for (const providerSettings of visibleProviderSettings) {
- type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
- const legacyProviders = settings.providers as Record;
- const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
- string,
- LegacyProviderSettings
- >;
- const driver = providerSettings.provider;
- const defaultInstanceId = defaultInstanceIdForDriver(driver);
- const explicitInstance = settings.providerInstances?.[defaultInstanceId];
- const legacyConfig = legacyProviders[providerSettings.provider]!;
- const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!;
- const effectiveInstance: ProviderInstanceConfig =
- explicitInstance ??
- ({
- driver,
- enabled: legacyConfig.enabled,
- config: legacyConfig,
- } satisfies ProviderInstanceConfig);
- const isDirty =
- explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
- rows.push({
- instanceId: defaultInstanceId,
- instance: effectiveInstance,
- driver,
- isDefault: true,
- isDirty,
- });
- for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
- if (id === defaultInstanceId) continue;
- rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
- }
- }
- for (const [driver, list] of instancesByDriver) {
- if (visibleDriverKinds.has(driver)) continue;
- for (const [id, instance] of list) {
- rows.push({
- instanceId: id,
- instance,
- driver: instance.driver,
- isDefault: defaultSlotIdsBySource.has(String(id)),
- });
- }
- }
-
- const updateProviderInstance = (
- row: InstanceRow,
- next: ProviderInstanceConfig,
- options?: {
- readonly textGenerationModelSelection?: Parameters<
- typeof buildProviderInstanceUpdatePatch
- >[0]["textGenerationModelSelection"];
- },
- ) => {
- updateSettings(
- buildProviderInstanceUpdatePatch({
- settings,
- instanceId: row.instanceId,
- instance: next,
- driver: row.driver,
- isDefault: row.isDefault,
- textGenerationModelSelection: options?.textGenerationModelSelection,
- }),
- );
- };
-
- const deleteProviderInstance = (id: ProviderInstanceId) => {
- updateSettings({
- providerInstances: withoutProviderInstanceKey(settings.providerInstances, id),
- providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id),
- favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id),
- });
- };
-
- const updateProviderModelPreferences = (
- instanceId: ProviderInstanceId,
- next: {
- readonly hiddenModels: ReadonlyArray;
- readonly modelOrder: ReadonlyArray;
- },
- ) => {
- const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
- const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
- const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
- updateSettings({
- providerModelPreferences:
- hiddenModels.length === 0 && modelOrder.length === 0
- ? rest
- : {
- ...rest,
- [instanceId]: {
- hiddenModels,
- modelOrder,
- },
- },
- });
- };
-
- const updateProviderFavoriteModels = (
- instanceId: ProviderInstanceId,
- nextFavoriteModels: ReadonlyArray,
- ) => {
- const favoriteModels = [
- ...new Set(
- Arr.filterMap(nextFavoriteModels, (slug) => {
- const trimmedSlug = slug.trim();
- return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid;
- }),
- ),
- ];
- updateSettings({
- favorites: [
- ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
- ...favoriteModels.map((model) => ({ provider: instanceId, model })),
- ],
- });
- };
-
- const resetDefaultInstance = (driverKind: ProviderDriverKind) => {
- type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
- const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
- string,
- LegacyProviderSettings | undefined
- >;
- const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
- const defaultLegacyProvider = defaultLegacyProviders[driverKind];
- if (defaultLegacyProvider === undefined) return;
- updateSettings({
- providers: {
- ...settings.providers,
- [driverKind]: defaultLegacyProvider,
- } as typeof settings.providers,
- providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId),
- providerModelPreferences: withoutProviderInstanceKey(
- settings.providerModelPreferences,
- defaultInstanceId,
- ),
- favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId),
- });
- };
-
- return (
-
-
-
-
- setIsAddInstanceDialogOpen(true)}
- aria-label="Add provider instance"
- >
-
-
- }
- />
- Add provider instance
-
-
- void refreshProviders()}
- aria-label="Refresh provider status"
- >
- {isRefreshingProviders ? (
-
- ) : (
-
- )}
-
- }
- />
- Refresh provider status
-
-
- }
- >
-
- Health check interval
-
- This interval is configured here, then the shared Background activity policy decides
- whether provider probes may run when the timer fires. Custom intervals appear as
- Advanced in General settings.
-
-
- }
- description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes."
- resetAction={
- providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
-
- updateSettings(
- backgroundActivityOverrideSettings(
- settings.backgroundActivity,
- resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: undefined,
- },
- ),
- )
- }
- />
- ) : null
- }
- control={
-
-
- updateSettings(
- backgroundActivityOverrideSettings(
- settings.backgroundActivity,
- resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
- ),
- )
- }
- >
-
-
-
-
-
-
- seconds
-
- }
- />
-
- {rows.map((row) => {
- const driverOption = getDriverOption(row.driver);
- const liveProvider = serverProviders.find(
- (candidate) => candidate.instanceId === row.instanceId,
- );
- const updateCandidate = liveProvider
- ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
- : undefined;
- const isDriverUpdateRunning =
- updateCandidate !== undefined &&
- (updatingProviderDrivers.has(updateCandidate.driver) ||
- serverProviders.some(
- (provider) =>
- provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
- ));
- const showInlineUpdateButton =
- updateCandidate !== undefined &&
- hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
- const canRunInlineUpdate =
- updateCandidate !== undefined &&
- canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
- !updatingProviderDrivers.has(updateCandidate.driver);
- const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
- hiddenModels: [],
- modelOrder: [],
- };
- const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
- favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
- );
- const resetLabel = driverOption?.label ?? String(row.driver);
- const headerAction =
- row.isDefault && row.isDirty ? (
- resetDefaultInstance(row.driver)}
- />
- ) : null;
- return (
-
- setOpenInstanceDetails((existing) => ({
- ...existing,
- [row.instanceId]: open,
- }))
- }
- onUpdate={(next) => {
- const wasEnabled = row.instance.enabled ?? true;
- const isDisabling = next.enabled === false && wasEnabled;
- const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
- if (shouldClearTextGen) {
- updateProviderInstance(row, next, {
- textGenerationModelSelection:
- DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
- });
- } else {
- updateProviderInstance(row, next);
- }
- }}
- onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)}
- headerAction={headerAction}
- hiddenModels={modelPreferences.hiddenModels}
- favoriteModels={favoriteModels}
- modelOrder={modelPreferences.modelOrder}
- onHiddenModelsChange={(hiddenModels) =>
- updateProviderModelPreferences(row.instanceId, {
- ...modelPreferences,
- hiddenModels,
- })
- }
- onFavoriteModelsChange={(favoriteModels) =>
- updateProviderFavoriteModels(row.instanceId, favoriteModels)
- }
- onModelOrderChange={(modelOrder) =>
- updateProviderModelPreferences(row.instanceId, {
- ...modelPreferences,
- modelOrder,
- })
- }
- onRunUpdate={
- showInlineUpdateButton && updateCandidate
- ? () => {
- if (!canRunInlineUpdate) {
- return;
- }
- void runProviderUpdate(updateCandidate);
- }
- : undefined
- }
- isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
- />
- );
- })}
-
-
- {isAddInstanceDialogOpen ? (
-
- ) : null}
-
- );
-}
-
export function ArchivedThreadsPanel() {
const projects = useProjects();
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index 84fb95a47417..0bb1a1aa6784 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -1,4 +1,4 @@
-import { Undo2Icon } from "lucide-react";
+import { InfoIcon, Undo2Icon } from "lucide-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
createContext,
@@ -83,6 +83,28 @@ function useSettingsSearchTarget(id: string | undefined)
return targetRef;
}
+/** Info affordance explaining how a setting interacts with the shared background policy. */
+export function PolicyTooltip({ children }: { readonly children: string }) {
+ return (
+
+
+
+
+ }
+ />
+
+ {children}
+
+
+ );
+}
+
/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
export function useRelativeTimeTick(intervalMs = 1_000) {
const [nowMs, setNowMs] = useState(() => Date.now());
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 1ba231a58350..3b3a8c220ac4 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -110,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Provider update checks",
to: "/settings/general",
},
- {
- id: "auto-open-task-panel",
- title: "Auto-open task panel",
- to: "/settings/general",
- },
{
id: "new-threads",
title: "New threads",
@@ -182,6 +177,11 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/beta",
targetId: "sidebar-v2",
},
+ {
+ id: "restore-plan-mode",
+ title: "Restore plan mode (legacy)",
+ to: "/settings/beta",
+ },
{
id: "archive",
title: "Archived threads",
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 06a0e714a6ea..89120850f202 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -44,7 +44,7 @@ function SidebarUpdateReleaseNotesTooltip({
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 &&
}
@@ -203,7 +203,9 @@ export function SidebarUpdatePill() {
align="start"
className={
state?.channel === "nightly" && state.releaseNotes.length > 0
- ? "max-w-none text-balance"
+ ? // pointer-events-auto overrides the positioner's pointer-events-none so the
+ // release notes stay open (and scrollable) when the cursor moves into them.
+ "pointer-events-auto max-w-none text-balance"
: undefined
}
side="top"
diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts
index 623c3767d752..24c142380a64 100644
--- a/apps/web/src/connection/platform.ts
+++ b/apps/web/src/connection/platform.ts
@@ -625,7 +625,7 @@ const rpcRequestObserverLayer = Layer.succeed(
Effect.sync(() => {
nextObservedRpcRequestId += 1;
const requestId = `${environmentId}:${nextObservedRpcRequestId}`;
- trackRpcRequestSent(requestId, `${method} · ${environmentId}`);
+ trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`);
return Effect.sync(() => {
acknowledgeRpcRequest(requestId);
});
diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts
index 5da93e3f5c55..4ae476c1d11f 100644
--- a/apps/web/src/connection/storage.ts
+++ b/apps/web/src/connection/storage.ts
@@ -51,9 +51,12 @@ const StoredShellSnapshot = Schema.Struct({
const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
// v2 stores the snapshot sequence alongside the thread so a warm cache can
// resume via `afterSequence` instead of re-downloading the full thread body.
-// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
+// v3 adds windowed (paginated) snapshots carrying `page` metadata. The bump
+// exists for rollback safety: a pre-pagination client would decode a windowed
+// v2 record, silently drop the unknown `page` field, and treat the partial
+// thread as complete forever. Older entries fail to decode → cold cache.
const StoredThreadSnapshot = Schema.Struct({
- schemaVersion: Schema.Literal(2),
+ schemaVersion: Schema.Literal(3),
environmentId: EnvironmentId,
threadId: ThreadId,
snapshot: OrchestrationThreadDetailSnapshot,
@@ -561,7 +564,7 @@ export const connectionStorageLayer = Layer.effectContext(
saveThread: (environmentId, snapshot) =>
Effect.gen(function* () {
const encoded = yield* encodeStoredThreadSnapshot({
- schemaVersion: 2,
+ schemaVersion: 3,
environmentId,
threadId: snapshot.thread.id,
snapshot,
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 54b3b5c0ce6f..3108ef2171bf 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -146,7 +146,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
compositor updates discrete frames instead of every vsync. */
--animate-status-pulse: status-pulse 2s infinite;
--animate-status-ping: status-ping 2s infinite;
- --animate-sidebar-working-text: sidebar-working-text 3.4s infinite;
--color-warning-foreground: var(--warning-foreground);
--color-warning: var(--warning);
--color-success-foreground: var(--success-foreground);
@@ -223,21 +222,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
scale: 2;
}
}
- @keyframes sidebar-working-text {
- 0%,
- 36% {
- opacity: 1;
- animation-timing-function: steps(10);
- }
- 50%,
- 86% {
- opacity: 0.75;
- animation-timing-function: steps(10);
- }
- 100% {
- opacity: 1;
- }
- }
}
@layer base {
diff --git a/apps/web/src/planSidebarDismissal.ts b/apps/web/src/planSidebarDismissal.ts
deleted file mode 100644
index b92cfaf899e6..000000000000
--- a/apps/web/src/planSidebarDismissal.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Tracks which turn's plan sidebar the user explicitly dismissed, per thread.
- *
- * Kept outside React state so a dismissal survives leaving and re-entering a
- * thread (ChatView resets its per-thread refs on navigation). Dismissals are
- * keyed by turn, so when a new turn produces fresh plan steps the sidebar
- * still auto-opens.
- */
-const dismissedTurnByThreadKey = new Map
();
-
-export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void {
- dismissedTurnByThreadKey.set(threadKey, turnKey);
-}
-
-export function clearPlanSidebarDismissal(threadKey: string): void {
- dismissedTurnByThreadKey.delete(threadKey);
-}
-
-export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean {
- return dismissedTurnByThreadKey.get(threadKey) === turnKey;
-}
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index c7457cfd3040..69831242f2f4 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -102,6 +102,41 @@ describe("rightPanelStore", () => {
});
});
+ it("drops persisted plan surfaces and does not reopen an empty panel", () => {
+ expect(
+ migratePersistedRightPanelState({
+ byThreadKey: {
+ "env-1:thread-A": {
+ isOpen: true,
+ activeSurfaceId: "plan",
+ surfaces: [{ id: "plan", kind: "plan" }],
+ },
+ "env-1:thread-B": {
+ isOpen: true,
+ activeSurfaceId: "plan",
+ surfaces: [
+ { id: "plan", kind: "plan" },
+ { id: "diff", kind: "diff" },
+ ],
+ },
+ },
+ }),
+ ).toEqual({
+ byThreadKey: {
+ "env-1:thread-A": {
+ isOpen: false,
+ activeSurfaceId: null,
+ surfaces: [],
+ },
+ "env-1:thread-B": {
+ isOpen: true,
+ activeSurfaceId: "diff",
+ surfaces: [{ id: "diff", kind: "diff" }],
+ },
+ },
+ });
+ });
+
it("open sets the active panel for a thread", () => {
useRightPanelStore.getState().open(refA, "preview");
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
@@ -109,7 +144,7 @@ describe("rightPanelStore", () => {
});
it("opening a different kind keeps both surfaces and activates the new one", () => {
- useRightPanelStore.getState().open(refA, "plan");
+ useRightPanelStore.getState().open(refA, "agents");
useRightPanelStore.getState().open(refA, "preview");
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
expect(
@@ -119,7 +154,7 @@ describe("rightPanelStore", () => {
it("reopening an inactive singleton activates its existing surface", () => {
useRightPanelStore.getState().open(refA, "diff");
- useRightPanelStore.getState().open(refA, "plan");
+ useRightPanelStore.getState().open(refA, "agents");
useRightPanelStore.getState().open(refA, "diff");
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
@@ -127,7 +162,7 @@ describe("rightPanelStore", () => {
activeSurfaceId: "diff",
surfaces: [
{ id: "diff", kind: "diff" },
- { id: "plan", kind: "plan" },
+ { id: "agents", kind: "agents" },
],
});
});
@@ -207,15 +242,15 @@ describe("rightPanelStore", () => {
it("removes persisted file surfaces when their workspace no longer exists", () => {
useRightPanelStore.getState().openFile(refA, "src/index.ts");
- useRightPanelStore.getState().open(refA, "plan");
+ useRightPanelStore.getState().open(refA, "agents");
useRightPanelStore.getState().openFile(refA, "README.md");
useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
isOpen: true,
- activeSurfaceId: "plan",
- surfaces: [{ id: "plan", kind: "plan" }],
+ activeSurfaceId: "agents",
+ surfaces: [{ id: "agents", kind: "agents" }],
});
useRightPanelStore.getState().openFile(refB, "conductor.json");
@@ -228,13 +263,13 @@ describe("rightPanelStore", () => {
});
it("close hides the panel without clearing its selected surface", () => {
- useRightPanelStore.getState().open(refA, "plan");
+ useRightPanelStore.getState().open(refA, "agents");
useRightPanelStore.getState().close(refA);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
isOpen: false,
- activeSurfaceId: "plan",
- surfaces: [{ id: "plan", kind: "plan" }],
+ activeSurfaceId: "agents",
+ surfaces: [{ id: "agents", kind: "agents" }],
});
});
@@ -264,12 +299,12 @@ describe("rightPanelStore", () => {
it("toggle to a different kind switches active", () => {
useRightPanelStore.getState().toggle(refA, "preview");
- useRightPanelStore.getState().toggle(refA, "plan");
- expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");
+ useRightPanelStore.getState().toggle(refA, "agents");
+ expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents");
});
it("removeThread clears persisted state", () => {
- useRightPanelStore.getState().open(refA, "plan");
+ useRightPanelStore.getState().open(refA, "agents");
useRightPanelStore.getState().removeThread(refA);
expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
});
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index cccb7238ca8d..2e72c7b4e10a 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -5,7 +5,7 @@
* surface descriptors and the active surface, while each feature continues to
* own its durable resource state. Browser surfaces point at preview tab ids,
* terminal surfaces point at terminal session ids, file surfaces point at
- * workspace paths, and diff/plan/files remain singleton surfaces.
+ * workspace paths, and diff/files remain singleton surfaces.
*/
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef } from "@t3tools/contracts";
@@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { resolveStorage } from "./lib/storage";
export const RIGHT_PANEL_KINDS = [
- "plan",
"diff",
"files",
"file",
@@ -45,11 +44,11 @@ export type RightPanelSurface =
revealLine: number | null;
revealRequestId: number;
}
- | { id: "plan"; kind: "plan" }
| { id: "agents"; kind: "agents" };
const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
-const RIGHT_PANEL_STORAGE_VERSION = 8;
+// v9 removed the "plan" surface kind (plans render inline in the transcript).
+const RIGHT_PANEL_STORAGE_VERSION = 9;
export interface ThreadRightPanelState {
isOpen: boolean;
@@ -99,8 +98,6 @@ const singletonSurface = (
return { id: "diff", kind };
case "files":
return { id: "files", kind };
- case "plan":
- return { id: "plan", kind };
case "agents":
return { id: "agents", kind };
}
@@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
threadState && typeof threadState === "object" ? threadState : null;
const surfaces = Array.isArray(validThreadState?.surfaces)
? validThreadState.surfaces.flatMap((surface) => {
+ // Dropped surface kind: plans now render inline in the
+ // transcript (v9).
+ if ((surface as { kind?: string }).kind === "plan") return [];
if (surface.kind === "file") {
const revealLine =
typeof surface.revealLine === "number" &&
@@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
];
})
: [];
- const activeSurfaceId = surfaces.some(
+ const persistedActiveSurfaceId = surfaces.some(
(surface) => surface.id === validThreadState?.activeSurfaceId,
)
? (validThreadState?.activeSurfaceId ?? null)
: null;
+ // A migration that dropped every surface (e.g. plan-only panels
+ // in v9) must not reopen an empty panel.
const isOpen =
- typeof validThreadState?.isOpen === "boolean"
+ surfaces.length > 0 &&
+ (typeof validThreadState?.isOpen === "boolean"
? validThreadState.isOpen
- : activeSurfaceId !== null;
+ : persistedActiveSurfaceId !== null);
+ // An open panel needs an active surface: if migration dropped
+ // the persisted one (e.g. plan was active), fall back to the
+ // first survivor instead of rendering an open empty panel.
+ const activeSurfaceId =
+ persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);
return [threadKey, { isOpen, surfaces, activeSurfaceId }];
},
),
diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx
index a7a86c2b50b0..deab014722dc 100644
--- a/apps/web/src/routes/settings.providers.tsx
+++ b/apps/web/src/routes/settings.providers.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
-import { ProviderSettingsPanel } from "../components/settings/SettingsPanels";
+import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
function SettingsProvidersRoute() {
return ;
diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts
index 504c93e1f78d..e5b3144d2520 100644
--- a/apps/web/src/rpc/requestLatencyState.test.ts
+++ b/apps/web/src/rpc/requestLatencyState.test.ts
@@ -6,6 +6,7 @@ import {
getSlowRpcAckRequests,
resetRequestLatencyStateForTests,
trackRpcRequestSent,
+ LONG_RUNNING_RPC_ACK_THRESHOLD_MS,
SLOW_RPC_ACK_THRESHOLD_MS,
MAX_TRACKED_RPC_ACK_REQUESTS,
} from "./requestLatencyState";
@@ -58,6 +59,32 @@ describe("requestLatencyState", () => {
expect(getSlowRpcAckRequests()).toEqual([]);
});
+ it("keeps ignoring untracked methods when a display tag is supplied", () => {
+ trackRpcRequestSent(
+ "1",
+ WS_METHODS.previewAutomationConnect,
+ `${WS_METHODS.previewAutomationConnect} · env-1`,
+ );
+ vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
+
+ expect(getSlowRpcAckRequests()).toEqual([]);
+ });
+
+ it("gives provider updates a longer threshold before warning", () => {
+ trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1");
+ vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1);
+ expect(getSlowRpcAckRequests()).toEqual([]);
+
+ vi.advanceTimersByTime(1);
+ expect(getSlowRpcAckRequests()).toMatchObject([
+ {
+ requestId: "1",
+ tag: "server.updateProvider · env-1",
+ thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS,
+ },
+ ]);
+ });
+
it("evicts the oldest pending requests once the tracker reaches capacity", () => {
for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {
trackRpcRequestSent(String(index), "server.getConfig");
diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts
index 1d8663edcfdd..c9d40700ea4c 100644
--- a/apps/web/src/rpc/requestLatencyState.ts
+++ b/apps/web/src/rpc/requestLatencyState.ts
@@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity";
import { appAtomRegistry } from "./atomRegistry";
export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000;
+/**
+ * Some requests are slow by design — they shell out to a package manager on the
+ * server and only respond once the install finishes. Warning about those after
+ * 15s is noise, so they get a much longer leash.
+ */
+export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000;
export const MAX_TRACKED_RPC_ACK_REQUESTS = 256;
let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS;
@@ -22,6 +28,12 @@ interface PendingRpcAckRequest {
}
const pendingRpcAckRequests = new Map();
+const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]);
+const longRunningRpcAckMethods = new Set([
+ WS_METHODS.serverUpdateProvider,
+ WS_METHODS.serverRefreshProviders,
+ WS_METHODS.serverUpdateServer,
+]);
const slowRpcAckRequestsAtom = Atom.make>([]).pipe(
Atom.keepAlive,
@@ -36,8 +48,8 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray {
return appAtomRegistry.get(slowRpcAckRequestsAtom);
}
-function shouldTrackRpcAck(tag: string): boolean {
- if (tag === WS_METHODS.previewAutomationConnect) {
+function shouldTrackRpcAck(method: string): boolean {
+ if (untrackedRpcAckMethods.has(method)) {
return false;
}
@@ -45,15 +57,26 @@ function shouldTrackRpcAck(tag: string): boolean {
// later than the user-visible payload). Match `subscribe` at the start of
// the tag or after a path-segment delimiter so `thread/unsubscribe` and
// similar still get tracked.
- return !/(?:^|[./:])subscribe/i.test(tag);
+ return !/(?:^|[./:])subscribe/i.test(method);
+}
+
+function rpcAckThresholdMs(method: string): number {
+ return longRunningRpcAckMethods.has(method)
+ ? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS)
+ : slowRpcAckThresholdMs;
}
export function getSlowRpcAckRequests(): ReadonlyArray {
return getSlowRpcAckRequestsValue();
}
-export function trackRpcRequestSent(requestId: string, tag: string): void {
- if (!shouldTrackRpcAck(tag)) {
+/**
+ * Starts the slow-request timer for one in-flight unary RPC. `method` is the
+ * bare WS method (used to decide whether and how long to wait); `tag` is the
+ * human-readable label shown in the toast, which defaults to the method.
+ */
+export function trackRpcRequestSent(requestId: string, method: string, tag = method): void {
+ if (!shouldTrackRpcAck(method)) {
return;
}
@@ -61,17 +84,18 @@ export function trackRpcRequestSent(requestId: string, tag: string): void {
evictOldestPendingRpcRequestIfNeeded();
const startedAtMs = Date.now();
+ const thresholdMs = rpcAckThresholdMs(method);
const request: SlowRpcAckRequest = {
requestId,
startedAt: new Date(startedAtMs).toISOString(),
startedAtMs,
tag,
- thresholdMs: slowRpcAckThresholdMs,
+ thresholdMs,
};
const timeoutId = setTimeout(() => {
pendingRpcAckRequests.delete(requestId);
appendSlowRpcAckRequest(request);
- }, slowRpcAckThresholdMs);
+ }, thresholdMs);
pendingRpcAckRequests.set(requestId, {
request,
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index f17f2364062f..f1b6ecd5c957 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test";
import {
deriveActiveWorkStartedAt,
deriveActivePlanState,
+ deriveTurnPlans,
derivePendingApprovals,
derivePendingUserInputs,
deriveTimelineEntries,
deriveWorkLogEntries,
findLatestProposedPlan,
- findSidebarProposedPlan,
hasActionableProposedPlan,
isLatestTurnSettled,
PROVIDER_OPTIONS,
@@ -411,6 +411,95 @@ describe("deriveActivePlanState", () => {
});
});
+describe("deriveTurnPlans", () => {
+ it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "plan-1a",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-1",
+ payload: {
+ plan: [{ step: "Inspect code", status: "inProgress" }],
+ },
+ }),
+ makeActivity({
+ id: "plan-1b",
+ createdAt: "2026-02-23T00:00:05.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-1",
+ payload: {
+ plan: [{ step: "Inspect code", status: "completed" }],
+ },
+ }),
+ makeActivity({
+ id: "plan-2a",
+ createdAt: "2026-02-23T00:01:00.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-2",
+ payload: {
+ plan: [{ step: "Ship it", status: "pending" }],
+ },
+ }),
+ ];
+
+ const turnPlans = deriveTurnPlans(activities);
+ expect(turnPlans).toHaveLength(2);
+ expect(turnPlans[0]).toMatchObject({
+ id: "turn-plan:turn-1",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ turnId: "turn-1",
+ });
+ expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]);
+ expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
+ });
+
+ it("skips activities without parseable steps", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "plan-bad",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-1",
+ payload: { plan: [] },
+ }),
+ ];
+ expect(deriveTurnPlans(activities)).toEqual([]);
+ });
+
+ it("drops a turn's chip when a later snapshot clears the plan", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "plan-set",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-1",
+ payload: { plan: [{ step: "Inspect code", status: "inProgress" }] },
+ }),
+ makeActivity({
+ id: "plan-clear",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "turn.plan.updated",
+ summary: "Plan updated",
+ tone: "info",
+ turnId: "turn-1",
+ payload: { plan: [] },
+ }),
+ ];
+ expect(deriveTurnPlans(activities)).toEqual([]);
+ });
+});
+
describe("findLatestProposedPlan", () => {
it("prefers the latest proposed plan for the active turn", () => {
expect(
@@ -516,103 +605,6 @@ describe("hasActionableProposedPlan", () => {
});
});
-describe("findSidebarProposedPlan", () => {
- it("prefers the running turn source proposed plan when available on the same thread", () => {
- expect(
- findSidebarProposedPlan({
- threads: [
- {
- id: ThreadId.make("thread-1"),
- proposedPlans: [
- {
- id: "plan-1",
- turnId: TurnId.make("turn-plan"),
- planMarkdown: "# Source plan",
- implementedAt: "2026-02-23T00:00:03.000Z",
- implementationThreadId: ThreadId.make("thread-2"),
- createdAt: "2026-02-23T00:00:01.000Z",
- updatedAt: "2026-02-23T00:00:02.000Z",
- },
- ],
- },
- {
- id: ThreadId.make("thread-2"),
- proposedPlans: [
- {
- id: "plan-2",
- turnId: TurnId.make("turn-other"),
- planMarkdown: "# Latest elsewhere",
- implementedAt: null,
- implementationThreadId: null,
- createdAt: "2026-02-23T00:00:04.000Z",
- updatedAt: "2026-02-23T00:00:05.000Z",
- },
- ],
- },
- ],
- latestTurn: {
- turnId: TurnId.make("turn-implementation"),
- sourceProposedPlan: {
- threadId: ThreadId.make("thread-1"),
- planId: "plan-1",
- },
- },
- latestTurnSettled: false,
- threadId: ThreadId.make("thread-1"),
- }),
- ).toEqual({
- id: "plan-1",
- turnId: "turn-plan",
- planMarkdown: "# Source plan",
- implementedAt: "2026-02-23T00:00:03.000Z",
- implementationThreadId: "thread-2",
- createdAt: "2026-02-23T00:00:01.000Z",
- updatedAt: "2026-02-23T00:00:02.000Z",
- });
- });
-
- it("falls back to the latest proposed plan once the turn is settled", () => {
- expect(
- findSidebarProposedPlan({
- threads: [
- {
- id: ThreadId.make("thread-1"),
- proposedPlans: [
- {
- id: "plan-1",
- turnId: TurnId.make("turn-plan"),
- planMarkdown: "# Older",
- implementedAt: null,
- implementationThreadId: null,
- createdAt: "2026-02-23T00:00:01.000Z",
- updatedAt: "2026-02-23T00:00:02.000Z",
- },
- {
- id: "plan-2",
- turnId: TurnId.make("turn-latest"),
- planMarkdown: "# Latest",
- implementedAt: null,
- implementationThreadId: null,
- createdAt: "2026-02-23T00:00:03.000Z",
- updatedAt: "2026-02-23T00:00:04.000Z",
- },
- ],
- },
- ],
- latestTurn: {
- turnId: TurnId.make("turn-implementation"),
- sourceProposedPlan: {
- threadId: ThreadId.make("thread-1"),
- planId: "plan-1",
- },
- },
- latestTurnSettled: true,
- threadId: ThreadId.make("thread-1"),
- })?.planMarkdown,
- ).toBe("# Latest");
- });
-});
-
describe("workEntryIndicatesToolFailure", () => {
const base = {
id: "w1",
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 88a25920d9b3..aa0d5b7de23d 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -161,6 +161,12 @@ export type TimelineEntry =
createdAt: string;
proposedPlan: ProposedPlan;
}
+ | {
+ id: string;
+ kind: "turn-plan";
+ createdAt: string;
+ turnPlan: TurnPlanEntry;
+ }
| {
id: string;
kind: "work";
@@ -542,26 +548,10 @@ export function derivePendingUserInputs(
);
}
-export function deriveActivePlanState(
- activities: ReadonlyArray,
- latestTurnId: TurnId | undefined,
-): ActivePlanState | null {
- const ordered = [...activities].toSorted(compareActivitiesByOrder);
- const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
- // Prefer plan from the current turn; fall back to the most recent plan from any turn
- // so that TodoWrite tasks persist across follow-up messages.
- const latest = Option.firstSomeOf([
- ...(latestTurnId
- ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
- : Option.none()),
- Arr.last(allPlanActivities),
- ]).pipe(Option.getOrNull);
- if (!latest) {
- return null;
- }
+function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null {
const payload =
- latest.payload && typeof latest.payload === "object"
- ? (latest.payload as Record)
+ activity.payload && typeof activity.payload === "object"
+ ? (activity.payload as Record)
: null;
const rawPlan = payload?.plan;
if (!Array.isArray(rawPlan)) {
@@ -590,8 +580,8 @@ export function deriveActivePlanState(
return null;
}
return {
- createdAt: latest.createdAt,
- turnId: latest.turnId,
+ createdAt: activity.createdAt,
+ turnId: activity.turnId,
...(payload && "explanation" in payload
? { explanation: payload.explanation as string | null }
: {}),
@@ -599,6 +589,72 @@ export function deriveActivePlanState(
};
}
+export function deriveActivePlanState(
+ activities: ReadonlyArray,
+ latestTurnId: TurnId | undefined,
+): ActivePlanState | null {
+ const ordered = [...activities].toSorted(compareActivitiesByOrder);
+ const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
+ // Prefer plan from the current turn; fall back to the most recent plan from any turn
+ // so that TodoWrite tasks persist across follow-up messages.
+ const latest = Option.firstSomeOf([
+ ...(latestTurnId
+ ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
+ : Option.none()),
+ Arr.last(allPlanActivities),
+ ]).pipe(Option.getOrNull);
+ if (!latest) {
+ return null;
+ }
+ return planStateFromActivity(latest);
+}
+
+export interface TurnPlanEntry {
+ /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */
+ id: string;
+ /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */
+ createdAt: string;
+ turnId: TurnId | null;
+ plan: ActivePlanState;
+}
+
+/**
+ * One inline plan chip per turn that produced plan/todo steps: the latest
+ * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less
+ * plan activities collapse into a single chip keyed by thread order.
+ */
+export function deriveTurnPlans(
+ activities: ReadonlyArray,
+): TurnPlanEntry[] {
+ const ordered = [...activities].toSorted(compareActivitiesByOrder);
+ const byTurn = new Map();
+ for (const activity of ordered) {
+ if (activity.kind !== "turn.plan.updated") {
+ continue;
+ }
+ const plan = planStateFromActivity(activity);
+ const key = activity.turnId ?? "no-turn";
+ if (!plan) {
+ // A later snapshot with no steps clears the turn's plan; keeping the
+ // stale entry would freeze the chip on a withdrawn plan.
+ byTurn.delete(key);
+ continue;
+ }
+ const existing = byTurn.get(key);
+ if (existing) {
+ existing.plan = plan;
+ } else {
+ byTurn.set(key, {
+ id: `turn-plan:${key}`,
+ createdAt: activity.createdAt,
+ turnId: activity.turnId,
+ plan,
+ });
+ }
+ }
+ return [...byTurn.values()];
+}
+
export function findLatestProposedPlan(
proposedPlans: ReadonlyArray,
latestTurnId: TurnId | string | null | undefined,
@@ -629,30 +685,6 @@ export function findLatestProposedPlan(
return toLatestProposedPlanState(latestPlan);
}
-export function findSidebarProposedPlan(input: {
- threads: ReadonlyArray>;
- latestTurn: Pick | null;
- latestTurnSettled: boolean;
- threadId: ThreadId | string | null | undefined;
-}): LatestProposedPlanState | null {
- const activeThreadPlans =
- input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];
-
- if (!input.latestTurnSettled) {
- const sourceProposedPlan = input.latestTurn?.sourceProposedPlan;
- if (sourceProposedPlan) {
- const sourcePlan = input.threads
- .find((thread) => thread.id === sourceProposedPlan.threadId)
- ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId);
- if (sourcePlan) {
- return toLatestProposedPlanState(sourcePlan);
- }
- }
- }
-
- return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null);
-}
-
export function hasActionableProposedPlan(
proposedPlan: LatestProposedPlanState | Pick | null,
): boolean {
@@ -1553,6 +1585,7 @@ export function deriveTimelineEntries(
messages: ReadonlyArray,
proposedPlans: ReadonlyArray,
workEntries: ReadonlyArray,
+ turnPlans: ReadonlyArray = [],
): TimelineEntry[] {
const messageRows: TimelineEntry[] = messages.map((message) => ({
id: message.id,
@@ -1566,13 +1599,19 @@ export function deriveTimelineEntries(
createdAt: proposedPlan.createdAt,
proposedPlan,
}));
+ const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({
+ id: turnPlan.id,
+ kind: "turn-plan",
+ createdAt: turnPlan.createdAt,
+ turnPlan,
+ }));
const workRows: TimelineEntry[] = workEntries.map((entry) => ({
id: entry.id,
kind: "work",
createdAt: entry.createdAt,
entry,
}));
- return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
+ return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) =>
a.createdAt.localeCompare(b.createdAt),
);
}
diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts
index 3271eefd1e18..1071d8209dfe 100644
--- a/apps/web/src/state/server.ts
+++ b/apps/web/src/state/server.ts
@@ -33,7 +33,7 @@ interface PrimaryServerState {
}
const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = [];
-const EMPTY_SERVER_PROVIDERS: ReadonlyArray = [];
+export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = [];
const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = {
config: null,
latestEvent: null,
diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts
index 37fed3b188f2..a7d5a53d10d2 100644
--- a/apps/web/src/state/session.ts
+++ b/apps/web/src/state/session.ts
@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
-import { Atom } from "effect/unstable/reactivity";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) {
appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)),
);
}
+
+/**
+ * This client's authenticated session on one environment, as reported by that
+ * environment's `/api/auth/session` endpoint. `data` stays populated across
+ * SWR revalidations; `isPending` is only meaningful before the first resolve.
+ */
+export function useEnvironmentSessionState(environmentId: EnvironmentId) {
+ const result = useAtomValue(environmentSession.sessionStateAtom(environmentId));
+ return {
+ data: Option.getOrNull(AsyncResult.value(result)),
+ hasError: result._tag === "Failure",
+ isPending: result.waiting,
+ };
+}
diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts
new file mode 100644
index 000000000000..33351c35eb17
--- /dev/null
+++ b/apps/web/src/test/reactElementTree.ts
@@ -0,0 +1,27 @@
+import { isValidElement, type ReactElement } from "react";
+
+/**
+ * Depth-first search over a React element tree produced by calling a component
+ * as a plain function (see `reactHookHarness`). Descends through props so
+ * render-prop and slot-style children are reachable. Returns the first element
+ * the visitor accepts, or null.
+ */
+export function visitElements(
+ node: unknown,
+ visitor: (element: ReactElement>) => boolean,
+): ReactElement> | null {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = visitElements(child, visitor);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!isValidElement>(node)) return null;
+ if (visitor(node)) return node;
+ for (const value of Object.values(node.props)) {
+ const found = visitElements(value, visitor);
+ if (found) return found;
+ }
+ return null;
+}
diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts
new file mode 100644
index 000000000000..1b4b26fb6988
--- /dev/null
+++ b/apps/web/src/test/reactHookHarness.ts
@@ -0,0 +1,89 @@
+import type { Dispatch, SetStateAction } from "react";
+
+/**
+ * Minimal React hook shim for tests that call components as plain functions
+ * instead of mounting a renderer. Slots are keyed by call order, mirroring
+ * React's own rules-of-hooks contract, and `useMemoCache` emulates the React
+ * Compiler runtime so compiled components can execute unmodified.
+ *
+ * This module must stay free of runtime `react` imports: it is loaded from
+ * inside `vi.mock("react", ...)` factories, and a value import would recurse
+ * into the in-progress mock. Wire it up in each test file (mock calls cannot
+ * live here because vitest hoists them per test module):
+ *
+ * ```ts
+ * import { reactHookHarness } from "~/test/reactHookHarness";
+ *
+ * vi.mock("react", async (importOriginal) => {
+ * const actual = await importOriginal();
+ * const { reactHookHarness } = await import("~/test/reactHookHarness");
+ * return {
+ * ...actual,
+ * useCallback: reactHookHarness.useCallback,
+ * useMemo: reactHookHarness.useMemo,
+ * useRef: reactHookHarness.useRef,
+ * useState: reactHookHarness.useState,
+ * };
+ * });
+ * vi.mock("react/compiler-runtime", async () => {
+ * const { reactHookHarness } = await import("~/test/reactHookHarness");
+ * return { c: reactHookHarness.useMemoCache };
+ * });
+ * ```
+ *
+ * Call `beginRender()` before each component invocation and `reset()` in
+ * `beforeEach` to drop persisted state between tests.
+ */
+export function createReactHookHarness() {
+ let cursor = 0;
+ let slots: unknown[] = [];
+ const nextIndex = () => cursor++;
+
+ return {
+ beginRender() {
+ cursor = 0;
+ },
+ reset() {
+ cursor = 0;
+ slots = [];
+ },
+ useCallback(callback: T): T {
+ nextIndex();
+ return callback;
+ },
+ useMemo(factory: () => T): T {
+ nextIndex();
+ return factory();
+ },
+ useMemoCache(size: number): unknown[] {
+ const index = nextIndex();
+ if (!slots[index]) {
+ slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
+ }
+ return slots[index] as unknown[];
+ },
+ useRef(initialValue: T): { current: T } {
+ const index = nextIndex();
+ if (!slots[index]) {
+ slots[index] = { current: initialValue };
+ }
+ return slots[index] as { current: T };
+ },
+ useState(initialValue: T | (() => T)): [T, Dispatch>] {
+ const index = nextIndex();
+ if (index >= slots.length) {
+ slots[index] =
+ typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
+ }
+ const setValue: Dispatch> = (nextValue) => {
+ const previous = slots[index] as T;
+ slots[index] =
+ typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
+ };
+ return [slots[index] as T, setValue];
+ },
+ };
+}
+
+/** Shared instance so `vi.mock` factories and test bodies see the same slots. */
+export const reactHookHarness = createReactHookHarness();
diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md
index c8a0217919f7..c734f0f9dd74 100644
--- a/docs/internals/t3-connect.md
+++ b/docs/internals/t3-connect.md
@@ -14,8 +14,16 @@ For the wider system diagram, see
## Application Keys
-T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env`
-or `.env.local` file:
+T3 Connect is disabled in a fresh clone. To enable it for source builds against the production
+deployment, copy the repository-root example file:
+
+```sh
+cp .env.example .env
+```
+
+`.env.example` carries the production public identifiers (the same values baked into official
+release builds). To target a different Clerk application or relay, set the values yourself in a
+repository-root `.env` or `.env.local` file:
```dotenv
T3CODE_CLERK_PUBLISHABLE_KEY=
diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts
index a925859049ff..5e50c44d9610 100644
--- a/packages/client-runtime/src/connection/supervisor.test.ts
+++ b/packages/client-runtime/src/connection/supervisor.test.ts
@@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => {
const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt");
expect(firstAttempt).toBeDefined();
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
const attempts = spans.filter((span) => span.name === "relay.connection.attempt");
@@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(1);
- for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) {
+ for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) {
yield* TestClock.adjust(delay);
yield* eventuallyState(
supervisor.state,
@@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
const retrying = yield* awaitState(
supervisor.state,
@@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => {
},
});
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
expect(yield* Ref.get(harness.prepareCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
@@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 2,
@@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(3);
- yield* TestClock.adjust("999 millis");
+ yield* TestClock.adjust("2999 millis");
expect(yield* Ref.get(harness.prepareCount)).toBe(3);
yield* TestClock.adjust("1 milli");
yield* eventuallyState(
@@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "blocked" && state.attempt === 2,
@@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => {
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => {
expect(secondFailure.retryAt).not.toBeNull();
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
expect(yield* Ref.get(harness.sessionCount)).toBe(2);
yield* TestClock.adjust("1 second");
@@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2,
@@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connecting" && state.attempt === 2,
@@ -925,9 +925,14 @@ describe("EnvironmentSupervisor", () => {
}),
);
- it.effect("reconnects when the foreground liveness probe fails", () =>
+ it.effect("reconnects immediately when the foreground liveness probe fails", () =>
Effect.gen(function* () {
+ const allowReconnect = yield* Deferred.make();
const harness = yield* makeHarness({
+ prepare: (attempt) =>
+ attempt === 2
+ ? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION))
+ : Effect.succeed(PREPARED_CONNECTION),
probe: (attempt) =>
attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
});
@@ -937,15 +942,77 @@ describe("EnvironmentSupervisor", () => {
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active");
- yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
- yield* TestClock.adjust("1 second");
+ const reconnecting = yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connecting",
+ );
+ expect(reconnecting.attempt).toBe(1);
+ expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true);
+
+ // No TestClock advance: a failed wake probe skips the first backoff rung.
+ yield* Deferred.succeed(allowReconnect, undefined);
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
+ );
+
+ expect(yield* Ref.get(harness.sessionCount)).toBe(2);
+ expect(yield* Ref.get(harness.releaseCount)).toBe(1);
+ }).pipe(Effect.provide(TestClock.layer())),
+ );
+
+ it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ prepare: (attempt) =>
+ attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION),
+ probe: (attempt) =>
+ attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ yield* harness.wake("application-active");
+ // The immediate follow-up attempt fails: only the first attempt after
+ // the wake probe skips the ladder, so this failure backs off normally.
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "backoff" && state.attempt === 1,
+ );
+ yield* TestClock.adjust("2999 millis");
+ expect(yield* Ref.get(harness.prepareCount)).toBe(2);
+ yield* TestClock.adjust("1 milli");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
);
+ expect(yield* Ref.get(harness.prepareCount)).toBe(3);
+ }).pipe(Effect.provide(TestClock.layer())),
+ );
+
+ it.effect("uses the full tolerance window for a stalled desktop foreground probe", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ yield* harness.wake("application-active");
+ yield* TestClock.adjust("14999 millis");
+ expect(yield* Ref.get(harness.sessionCount)).toBe(1);
+ yield* TestClock.adjust("1 milli");
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
+ );
+
expect(yield* Ref.get(harness.sessionCount)).toBe(2);
- expect(yield* Ref.get(harness.releaseCount)).toBe(1);
}).pipe(Effect.provide(TestClock.layer())),
);
@@ -961,15 +1028,14 @@ describe("EnvironmentSupervisor", () => {
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active-probe");
yield* TestClock.adjust("3 seconds");
+ // The timed-out wake probe reconnects immediately without a backoff
+ // sleep: no further clock advance is needed.
yield* awaitState(
supervisor.state,
- (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout",
- );
- yield* TestClock.adjust("1 second");
- yield* eventuallyState(
- supervisor.state,
- (state) => state.phase === "connected" && state.generation === 2,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
);
+
+ expect(yield* Ref.get(harness.sessionCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
);
diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts
index 2a9c7519072b..85fda10ef1a7 100644
--- a/packages/client-runtime/src/connection/supervisor.ts
+++ b/packages/client-runtime/src/connection/supervisor.ts
@@ -29,7 +29,7 @@ import * as RpcSession from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import * as ConnectionWakeups from "./wakeups.ts";
-const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
+const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const;
const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
const CONNECTION_PROBE_TIMEOUT = "15 seconds";
const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds";
@@ -232,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
const intent = yield* Ref.make(initialIntent);
const signals = yield* Queue.unbounded();
const resetRetryState = yield* Ref.make(false);
+ // Set when a foreground wake probe fails or times out: the user is actively
+ // returning to the app on a dead transport, so the follow-up reconnect skips
+ // the first backoff rung instead of sleeping.
+ const wakeProbeFailed = yield* Ref.make(false);
const state = yield* SubscriptionRef.make(
!initialIntent.desired
? availableState(initialIntent, 0)
@@ -441,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
),
);
if (probeEvent._tag === "ProbeCompleted") {
+ if (Exit.isFailure(probeEvent.exit)) {
+ yield* Ref.set(wakeProbeFailed, true);
+ }
yield* probeEvent.exit;
break;
}
@@ -673,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
const outcome: AttemptOutcome = yield* Effect.scoped(
runAttempt(attempt, nextGeneration, latestFailure, pendingRetry),
);
+ // Consumed on every iteration so a stale marker can never leak into a
+ // later, unrelated failure.
+ const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false);
if (outcome.established) {
generation = nextGeneration;
if (outcome.stable) {
@@ -709,6 +719,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
continue;
}
+ if (failedWakeProbe) {
+ // The wake probe found a dead transport while the user is returning to
+ // the app, so reconnect immediately instead of sleeping the first
+ // backoff rung. Only this first attempt skips the ladder; if it fails
+ // too, normal backoff resumes.
+ resetRetryLadder();
+ yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error));
+ continue;
+ }
+
failureCount += 1;
const delayMs = retryDelayMs(failureCount - 1);
pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({
diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts
index f7868834b57c..0af5850bf6c7 100644
--- a/packages/client-runtime/src/rpc/session.test.ts
+++ b/packages/client-runtime/src/rpc/session.test.ts
@@ -287,6 +287,33 @@ describe("RpcSessionFactory", () => {
}),
);
+ it.effect("tolerates two missed pong windows before closing the session", () =>
+ Effect.gen(function* () {
+ const { factory, sockets } = yield* makeFactory();
+ const session = yield* factory.connect(PREPARED);
+ const readyFiber = yield* Effect.forkChild(session.ready);
+ const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed));
+ const socket = yield* awaitSocket(sockets);
+
+ socket.open();
+ yield* completeInitialConfig(socket);
+ yield* Fiber.join(readyFiber);
+
+ yield* TestClock.adjust("15 seconds");
+ expect(closedFiber.pollUnsafe()).toBeUndefined();
+ expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([
+ { _tag: "Ping" },
+ { _tag: "Ping" },
+ { _tag: "Ping" },
+ ]);
+
+ yield* TestClock.adjust("5 seconds");
+ const error = yield* Fiber.join(closedFiber);
+ expect(error).toBeInstanceOf(ConnectionTransientError);
+ expect(error).toMatchObject({ reason: "transport" });
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
+ );
+
it.effect("reaches ready when a newer server sends unknown config members", () =>
Effect.gen(function* () {
const { factory, sockets } = yield* makeFactory();
diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts
index e08fd9e552f2..d3bb6680208a 100644
--- a/packages/client-runtime/src/state/entities.test.ts
+++ b/packages/client-runtime/src/state/entities.test.ts
@@ -333,6 +333,7 @@ describe("environment entity projections", () => {
data: Option.some(detail),
status: "live",
error: Option.none(),
+ page: Option.none(),
}),
);
@@ -361,6 +362,7 @@ describe("environment entity projections", () => {
}),
status: "live",
error: Option.none(),
+ page: Option.none(),
}),
);
diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts
index 3cb62009a208..31fd297da3f0 100644
--- a/packages/client-runtime/src/state/session.ts
+++ b/packages/client-runtime/src/state/session.ts
@@ -1,14 +1,19 @@
-import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
+import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
+import type { HttpClient } from "effect/unstable/http";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { EnvironmentRegistry } from "../connection/registry.ts";
import type { PreparedConnection } from "../connection/model.ts";
import { EnvironmentSupervisor } from "../connection/supervisor.ts";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
+import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
+import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
+import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
import { followStreamInEnvironment } from "./runtime.ts";
export function initialConfigOption(
@@ -25,8 +30,39 @@ export function initialConfigOption(
);
}
+// Bounded like the snapshot fetches: a wedged environment must not pin the
+// permissions check (and with it the settings UI) in a loading state for long.
+const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
+
+/**
+ * Read the granted scopes of this client's session on one environment via its
+ * `/api/auth/session` endpoint, authenticated with whatever credential the
+ * connection was prepared with (cookie, bearer, or DPoP).
+ */
+export const fetchEnvironmentSessionState = Effect.fn(
+ "clientRuntime.state.fetchEnvironmentSessionState",
+)(function* (input: {
+ readonly prepared: PreparedConnection;
+ readonly signer: Option.Option;
+ readonly timeoutMs?: number;
+}) {
+ const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session");
+ const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
+ const headers = yield* buildEnvironmentAuthHeaders(
+ input.prepared.httpAuthorization,
+ "GET",
+ requestUrl,
+ input.signer,
+ );
+ return yield* executeEnvironmentHttpRequest(
+ requestUrl,
+ input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
+ withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })),
+ );
+});
+
export function createEnvironmentSessionAtoms(
- runtime: Atom.AtomRuntime,
+ runtime: Atom.AtomRuntime,
) {
const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
runtime.atom(
@@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms(
).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)),
);
+ // Keyed on the prepared connection's identity: a reconnect (new credential,
+ // new base URL) swaps the prepared value, which re-runs the fetch, so scope
+ // changes from re-pairing are picked up without an explicit refresh.
+ const sessionStateAtom = Atom.family((environmentId: EnvironmentId) =>
+ runtime
+ .atom((get) => {
+ const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
+ if (prepared === null) {
+ return Effect.never;
+ }
+ return Effect.gen(function* () {
+ const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
+ return yield* fetchEnvironmentSessionState({ prepared, signer });
+ });
+ })
+ .pipe(
+ Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
+ Atom.setIdleTTL(5 * 60_000),
+ Atom.withLabel(`environment-session-state:${environmentId}`),
+ ),
+ );
+
+ const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make(
+ (get): AuthSessionState | null =>
+ Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null,
+ ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)),
+ );
+
return {
initialConfigAtom,
initialConfigValueAtom,
preparedConnectionAtom,
preparedConnectionValueAtom,
+ sessionStateAtom,
+ sessionStateValueAtom,
};
}
diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts
index e006fc3cd762..40e9bd80dc5b 100644
--- a/packages/client-runtime/src/state/shell-sync.test.ts
+++ b/packages/client-runtime/src/state/shell-sync.test.ts
@@ -150,34 +150,34 @@ describe("environment shell synchronization", () => {
}),
);
- it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () =>
+ it.effect("requests a full socket snapshot when the HTTP refresh fails", () =>
Effect.gen(function* () {
const cachedSnapshot: OrchestrationShellSnapshot = {
snapshotSequence: 5,
projects: [],
- threads: [{ id: "stale-thread" } as never],
+ threads: [{ id: "cached-thread" } as never],
updatedAt: "2026-06-06T00:00:00.000Z",
};
- const httpSnapshot: OrchestrationShellSnapshot = {
+ const resetSnapshot: OrchestrationShellSnapshot = {
...cachedSnapshot,
- snapshotSequence: 9,
+ snapshotSequence: 9_999,
threads: [],
updatedAt: "2026-06-07T00:00:00.000Z",
};
const events = yield* Queue.unbounded();
- const capturedAfterSequence = yield* SubscriptionRef.make(undefined);
- const capturedCompletionMarker = yield* Ref.make(undefined);
- const loaderCalls = yield* SubscriptionRef.make(0);
+ const wakeups = yield* Queue.unbounded();
+ const subscribeInputs = yield* Queue.unbounded<{
+ readonly afterSequence?: number;
+ readonly requestCompletionMarker?: boolean;
+ }>();
+ const loaderCalls = yield* Ref.make(0);
const client = {
[ORCHESTRATION_WS_METHODS.subscribeShell]: (input: {
readonly afterSequence?: number;
readonly requestCompletionMarker?: boolean;
}) =>
Stream.unwrap(
- Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe(
- Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)),
- Effect.as(Stream.fromQueue(events)),
- ),
+ Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))),
),
} as unknown as WsRpcProtocolClient;
const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
@@ -208,57 +208,66 @@ describe("environment shell synchronization", () => {
clear: () => Effect.void,
});
const snapshotLoader = ShellSnapshotLoader.of({
- load: () =>
- SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(
- Effect.as(Option.some(httpSnapshot)),
- ),
+ load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())),
});
const shellState = yield* makeEnvironmentShellState().pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.provideService(Persistence.EnvironmentCacheStore, cache),
Effect.provideService(ShellSnapshotLoader, snapshotLoader),
+ Effect.provideService(
+ ConnectionWakeups.ConnectionWakeups,
+ ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }),
+ ),
);
- // Wait until the subscription is established from the warm cache.
- yield* SubscriptionRef.changes(capturedAfterSequence).pipe(
- Stream.filter((value) => value !== undefined),
- Stream.runHead,
- );
-
- expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9);
- expect(yield* Ref.get(capturedCompletionMarker)).toBe(true);
- expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1);
+ const subscribeInput = yield* Queue.take(subscribeInputs);
+ expect(subscribeInput.afterSequence).toBeUndefined();
+ expect(subscribeInput.requestCompletionMarker).toBe(true);
+ expect(yield* Ref.get(loaderCalls)).toBe(1);
const synchronizing = yield* SubscriptionRef.get(shellState);
expect(synchronizing.status).toBe("synchronizing");
- expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot);
+ expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot);
+ yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot });
yield* Queue.offer(events, { kind: "synchronized" });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter((value) => value.status === "live"),
Stream.runHead,
);
+
+ const live = yield* SubscriptionRef.get(shellState);
+ expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot);
+ expect(yield* Ref.get(loaderCalls)).toBe(1);
+
+ yield* Queue.offer(wakeups, "application-active");
+ const resumedInput = yield* Queue.take(subscribeInputs);
+ expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence);
+ expect(resumedInput.requestCompletionMarker).toBe(true);
+ expect(yield* Ref.get(loaderCalls)).toBe(1);
}),
);
- it.effect("refreshes the authoritative shell snapshot when the app becomes active", () =>
+ it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () =>
Effect.gen(function* () {
const events = yield* Queue.unbounded();
const wakeups = yield* Queue.unbounded();
const loaderCalls = yield* Ref.make(0);
- const subscriptionCount = yield* Ref.make(0);
+ const capturedAfterSequences = yield* Ref.make>([]);
const client = {
- [ORCHESTRATION_WS_METHODS.subscribeShell]: () =>
+ [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) =>
Stream.unwrap(
- Ref.update(subscriptionCount, (count) => count + 1).pipe(
- Effect.as(Stream.fromQueue(events)),
- ),
+ Ref.update(capturedAfterSequences, (captured) => [
+ ...captured,
+ input.afterSequence,
+ ]).pipe(Effect.as(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: yield* SubscriptionRef.make(Option.some(session(client))),
+ session: activeSession,
prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
connect: Effect.void,
disconnect: Effect.void,
@@ -296,54 +305,60 @@ describe("environment shell synchronization", () => {
),
);
- yield* SubscriptionRef.changes(shellState).pipe(
- Stream.filter(
- (value) =>
- value.status === "synchronizing" &&
- Option.isSome(value.snapshot) &&
- value.snapshot.value.snapshotSequence === 10,
- ),
- Stream.runHead,
- );
+ // A new session starts from an authoritative HTTP snapshot.
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break;
+ yield* Effect.yieldNow;
+ }
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([10]);
yield* Queue.offer(events, { kind: "synchronized" });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter((value) => value.status === "live"),
Stream.runHead,
);
- yield* Queue.offer(wakeups, "application-active");
+ // A newer snapshot arrives on the stream and advances the cursor.
+ yield* Queue.offer(events, {
+ kind: "snapshot",
+ snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 },
+ });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter(
- (value) =>
- value.status === "synchronizing" &&
- Option.isSome(value.snapshot) &&
- value.snapshot.value.snapshotSequence === 20,
+ (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40,
),
Stream.runHead,
);
+ yield* Queue.offer(wakeups, "application-active");
for (let attempt = 0; attempt < 100; attempt += 1) {
- if ((yield* Ref.get(subscriptionCount)) >= 2) break;
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break;
yield* Effect.yieldNow;
}
-
- expect(yield* Ref.get(loaderCalls)).toBe(2);
- expect(yield* Ref.get(subscriptionCount)).toBe(2);
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40]);
+ yield* Queue.offer(events, { kind: "synchronized" });
yield* Queue.offer(wakeups, "application-active-probe");
for (let attempt = 0; attempt < 100; attempt += 1) {
- if ((yield* Ref.get(subscriptionCount)) >= 3) break;
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break;
yield* Effect.yieldNow;
}
- expect(yield* Ref.get(loaderCalls)).toBe(3);
- expect(yield* Ref.get(subscriptionCount)).toBe(3);
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40]);
yield* Queue.offer(wakeups, "application-active-reconnect");
for (let attempt = 0; attempt < 10; attempt += 1) {
yield* Effect.yieldNow;
}
- expect(yield* Ref.get(loaderCalls)).toBe(3);
- expect(yield* Ref.get(subscriptionCount)).toBe(3);
+ expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3);
+ expect(yield* Ref.get(loaderCalls)).toBe(1);
+
+ // Replacing the session performs another authoritative refresh.
+ yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break;
+ yield* Effect.yieldNow;
+ }
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40, 20]);
+ expect(yield* Ref.get(loaderCalls)).toBe(2);
}),
);
});
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index a266af5f5f4e..c150bbb75b8c 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -21,6 +21,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import { EnvironmentCacheStore } from "../platform/persistence.ts";
import { subscribeDynamic } from "../rpc/client.ts";
+import type { RpcSession } from "../rpc/session.ts";
import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
import { applyShellStreamEvent } from "./shellReducer.ts";
import type { EnvironmentCatalogState } from "./connections.ts";
@@ -71,6 +72,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
error: Option.none(),
});
const awaitingCompletion = yield* Ref.make(false);
+ const lastAuthoritativeSession = yield* Ref.make(null);
+ const activeSubscriptionSession = yield* Ref.make(null);
const persistence = yield* Queue.sliding(1);
const persist = Effect.fn("EnvironmentShellState.persist")(function* (
@@ -166,6 +169,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
status: waiting ? "synchronizing" : "live",
error: Option.none(),
});
+ if (item.kind === "snapshot") {
+ const session = yield* Ref.get(activeSubscriptionSession);
+ if (session !== null) {
+ yield* Ref.set(lastAuthoritativeSession, session);
+ }
+ }
yield* Queue.offer(persistence, nextSnapshot);
});
@@ -180,6 +189,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
subscribeDynamic(
ORCHESTRATION_WS_METHODS.subscribeShell,
Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) {
+ yield* Ref.set(activeSubscriptionSession, session);
const supportsCompletionMarker = yield* session.initialConfig.pipe(
Effect.map((config) => config.shellResumeCompletionMarker === true),
Effect.orElseSucceed(() => false),
@@ -187,30 +197,53 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
yield* setSynchronizing;
- const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
- Effect.flatMap(
- Option.match({
- onSome: Effect.succeed,
- onNone: () =>
- SubscriptionRef.changes(supervisor.prepared).pipe(
- Stream.filter(Option.isSome),
- Stream.map((value) => value.value),
- Stream.runHead,
- Effect.map(Option.getOrThrow),
- ),
- }),
- ),
- );
- const httpSnapshot = yield* snapshotLoader.load(prepared);
- if (Option.isSome(httpSnapshot)) {
- yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
- return {
- afterSequence: httpSnapshot.value.snapshotSequence,
- ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
- };
+ // Foreground resubscriptions on the same live session can resume from
+ // the in-memory cursor. A new session reloads the authoritative HTTP
+ // snapshot so a valid cursor cannot preserve incomplete cached data.
+ const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session;
+ let canResume = hasAuthoritativeSnapshot;
+ let current = yield* SubscriptionRef.get(state);
+ if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) {
+ const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
+ Effect.flatMap(
+ Option.match({
+ onSome: Effect.succeed,
+ onNone: () =>
+ SubscriptionRef.changes(supervisor.prepared).pipe(
+ Stream.filter(Option.isSome),
+ Stream.map((value) => value.value),
+ Stream.runHead,
+ Effect.map(Option.getOrThrow),
+ ),
+ }),
+ ),
+ );
+ const httpSnapshot = yield* snapshotLoader.load(prepared);
+ if (Option.isSome(httpSnapshot)) {
+ yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
+ canResume = true;
+ current = yield* SubscriptionRef.get(state);
+ }
}
- return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ // If the authoritative refresh failed, omit the cached cursor so the
+ // socket fallback sends a complete snapshot for this new session.
+ if (!canResume || Option.isNone(current.snapshot)) {
+ return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ }
+ if (!supportsCompletionMarker) {
+ // Without a completion marker there is no synchronized signal for a
+ // resumed subscription, so report live immediately, like threads.
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ status: "live" as const,
+ error: Option.none(),
+ }));
+ }
+ return {
+ afterSequence: current.snapshot.value.snapshotSequence,
+ ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
+ };
}),
{
onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts
index c6c758511a3b..ceb40517550e 100644
--- a/packages/client-runtime/src/state/subagentRuntime.test.ts
+++ b/packages/client-runtime/src/state/subagentRuntime.test.ts
@@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => {
expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 });
});
+ it("usage snapshots enrich an existing agent without changing its status", () => {
+ const [agent] = fold([
+ activity("task.started", { taskId: "usage-waiting", taskType: "local_agent" }),
+ activity("task.progress", { taskId: "usage-waiting", status: "waiting" }),
+ activity("task.progress", {
+ taskId: "usage-waiting",
+ usageSnapshot: true,
+ typedUsage: { totalTokens: 1_200 },
+ }),
+ ]);
+
+ expect(agent?.status).toBe("waiting");
+ expect(agent?.usage?.totalTokens).toBe(1_200);
+ });
+
+ it("a retained usage snapshot can still reconstruct a running agent", () => {
+ const [agent] = fold([
+ activity("task.progress", {
+ taskId: "usage-only",
+ usageSnapshot: true,
+ typedUsage: { totalTokens: 800 },
+ }),
+ ]);
+
+ expect(agent?.status).toBe("running");
+ expect(agent?.usage?.totalTokens).toBe(800);
+ });
+
it("partial terminal usage preserves known breakdown fields", () => {
const agents = fold([
activity("task.started", { taskId: "task-6", taskType: "local_agent" }),
@@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => {
);
});
+ it("keeps direct spawns in first-seen order as their activity changes", () => {
+ const directRoster = fold([
+ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"),
+ activity("task.started", { taskId: "direct-b", title: "Second" }, "2026-08-01T11:00:01.000Z"),
+ activity(
+ "task.progress",
+ { taskId: "direct-a", summary: "Newest activity" },
+ "2026-08-01T11:00:02.000Z",
+ ),
+ ]);
+
+ expect(
+ deriveAgentPanelModel({ agents: directRoster }).directAgents.map((agent) => agent.id),
+ ).toEqual(["direct-a", "direct-b"]);
+ });
+
+ it("keeps first-seen order after the roster retention ranking runs", () => {
+ const starts = Array.from({ length: 101 }, (_, index) =>
+ activity(
+ "task.started",
+ { taskId: `capped-${index}`, title: `Agent ${index}` },
+ `2026-08-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(
+ index % 60,
+ ).padStart(2, "0")}.000Z`,
+ ),
+ );
+ const cappedRoster = fold([
+ ...starts,
+ activity(
+ "task.progress",
+ { taskId: "capped-0", summary: "Newest activity" },
+ "2026-08-01T12:02:00.000Z",
+ ),
+ ]);
+
+ const ids = deriveAgentPanelModel({ agents: cappedRoster }).directAgents.map(
+ (agent) => agent.id,
+ );
+ expect(ids).toHaveLength(100);
+ expect(ids.slice(0, 3)).toEqual(["capped-0", "capped-2", "capped-3"]);
+ expect(ids.at(-1)).toBe("capped-100");
+ });
+
it("a phase with only pending members never reads as running", () => {
const pendingRoster = fold([
activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }),
diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts
index c81dd6341409..e5f2b586b8c4 100644
--- a/packages/client-runtime/src/state/subagentRuntime.ts
+++ b/packages/client-runtime/src/state/subagentRuntime.ts
@@ -80,6 +80,8 @@ export interface RuntimeSubagent {
readonly phases: ReadonlyArray;
readonly runHandles: SubagentRunHandles | null;
readonly recentActivity: ReadonlyArray;
+ /** First retained observation, used as the roster's stable display order. */
+ readonly firstSeenAt: string;
readonly startedAt: string | null;
readonly completedAt: string | null;
readonly updatedAt: string;
@@ -247,6 +249,7 @@ interface MutableAgent {
phases: ReadonlyArray;
runHandles: SubagentRunHandles | null;
recentActivity: ReadonlyArray;
+ firstSeenAt: string;
startedAt: string | null;
completedAt: string | null;
updatedAt: string;
@@ -300,6 +303,7 @@ function getOrCreate(
phases: [],
runHandles: null,
recentActivity: [],
+ firstSeenAt: at,
startedAt: null,
completedAt: null,
updatedAt: at,
@@ -500,14 +504,19 @@ export function foldSubagentActivities(
// Membership is sticky per taskId: rows after the first (terminal
// rows often carry only taskId+status, no marker fields) inherit the
// first row's classification instead of being re-judged.
- if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break;
+ const existed = agents.has(taskId);
+ if (!existed && isBackgroundTaskActivity(payload)) break;
const agent = getOrCreate(agents, taskId, payload, at);
fillMetadata(agent, payload);
if (agent.activationCount === 0) agent.activationCount = 1;
const explicitStatus = asRuntimeStatus(payload.status);
if (explicitStatus) {
applyStatus(agent, explicitStatus, at);
- } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") {
+ } else if (
+ (payload.usageSnapshot !== true || !existed) &&
+ !isTerminalSubagentStatus(agent.status) &&
+ agent.status !== "idle"
+ ) {
applyStatus(agent, "running", at);
}
const summary = asString(payload.summary);
@@ -726,7 +735,10 @@ export function deriveAgentPanelModel({
return EMPTY_PANEL_MODEL;
}
- const workflows = source.filter((agent) => agent.kind === "workflow");
+ const workflows = source
+ .filter((agent) => agent.kind === "workflow")
+ .slice()
+ .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id));
const workflowIds = new Set(workflows.map((workflow) => workflow.id));
const members = new Map();
const direct: RuntimeSubagent[] = [];
@@ -827,7 +839,11 @@ export function deriveAgentPanelModel({
return {
workflows: workflowGroups,
- directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
+ // Updates and the >100-agent retention ranking must never reshuffle rows
+ // that remain visible.
+ directAgents: direct
+ .slice()
+ .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)),
runningCount,
waitingCount,
idleCount,
diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts
index 874bcc30ebdf..6acc3b5d8a4f 100644
--- a/packages/client-runtime/src/state/threadSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts
@@ -26,6 +26,16 @@ const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
* WebSocket subscription's first frame. The response is gzip-compressible by
* the transport and keeps the (potentially multi-KB) snapshot off the socket.
*/
+/**
+ * Optional turn window for a snapshot fetch. Only send a window to servers
+ * that advertise `threadSnapshotPagination`; older servers reject unknown
+ * query parameters.
+ */
+export interface ThreadSnapshotWindow {
+ readonly turnLimit: number;
+ readonly beforeCursor?: string;
+}
+
export const fetchEnvironmentThreadSnapshot = Effect.fn(
"clientRuntime.state.fetchEnvironmentThreadSnapshot",
)(function* (input: {
@@ -33,6 +43,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
readonly threadId: ThreadId;
readonly signer: Option.Option;
readonly timeoutMs?: number;
+ readonly window?: ThreadSnapshotWindow;
}) {
const requestUrl = environmentEndpointUrl(
input.prepared.httpBaseUrl,
@@ -52,6 +63,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
input.prepared.httpAuthorization,
client.orchestration.threadSnapshot({
params: { threadId: input.threadId },
+ payload: {
+ ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}),
+ ...(input.window?.beforeCursor !== undefined
+ ? { beforeCursor: input.window.beforeCursor }
+ : {}),
+ },
headers,
}),
),
@@ -72,6 +89,7 @@ export class ThreadSnapshotLoader extends Context.Service<
readonly load: (
prepared: PreparedConnection,
threadId: ThreadId,
+ window?: ThreadSnapshotWindow,
) => Effect.Effect>;
}
>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
@@ -89,8 +107,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer<
// connections work without one).
const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
return ThreadSnapshotLoader.of({
- load: (prepared: PreparedConnection, threadId: ThreadId) =>
- fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(
+ load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) =>
+ fetchEnvironmentThreadSnapshot({
+ prepared,
+ threadId,
+ signer,
+ ...(window !== undefined ? { window } : {}),
+ }).pipe(
Effect.map(Option.some),
Effect.provideService(HttpClient.HttpClient, httpClient),
// A genuinely missing thread (404) is expected — the socket
diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts
index 89be139e9256..8ba9696ec576 100644
--- a/packages/client-runtime/src/state/threadState.ts
+++ b/packages/client-runtime/src/state/threadState.ts
@@ -3,14 +3,38 @@ import * as Option from "effect/Option";
export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
+/**
+ * Pagination state for a windowed thread. Present only when the loaded thread
+ * is a window (the server returned `page` metadata); absent means the thread is
+ * fully loaded — either the server predates pagination or the window reached
+ * the top.
+ */
+export interface EnvironmentThreadPageState {
+ /** Opaque exclusive cursor for the next older slice; null when fully loaded. */
+ readonly beforeCursor: string | null;
+ readonly hasMore: boolean;
+ /** True while an older page fetch is in flight. */
+ readonly loadingOlder: boolean;
+}
+
export interface EnvironmentThreadState {
readonly data: Option.Option;
readonly status: EnvironmentThreadStatus;
readonly error: Option.Option;
+ readonly page: Option.Option;
}
export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
data: Option.none(),
status: "empty",
error: Option.none(),
+ page: Option.none(),
};
+
+/** Whether the thread has older turns that can be loaded with more pages. */
+export function threadHasOlderTurns(state: EnvironmentThreadState): boolean {
+ return Option.match(state.page, {
+ onNone: () => false,
+ onSome: (page) => page.hasMore,
+ });
+}
diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts
new file mode 100644
index 000000000000..62cad18f89e0
--- /dev/null
+++ b/packages/client-runtime/src/state/threads-pagination.test.ts
@@ -0,0 +1,543 @@
+import {
+ EnvironmentId,
+ EventId,
+ ORCHESTRATION_WS_METHODS,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+ TurnId,
+ type OrchestrationMessage,
+ type OrchestrationThread,
+ type OrchestrationThreadDetailSnapshot,
+ type OrchestrationThreadStreamItem,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
+import * as Ref from "effect/Ref";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+
+import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
+import {
+ AVAILABLE_CONNECTION_STATE,
+ PrimaryConnectionTarget,
+ type PreparedConnection,
+ type SupervisorConnectionState,
+} from "../connection/model.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import * as Persistence from "../platform/persistence.ts";
+import * as RpcSession from "../rpc/session.ts";
+import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts";
+import {
+ INITIAL_THREAD_USER_TURN_LIMIT,
+ makeEnvironmentThreadState,
+ requestOlderThreadTurns,
+ ThreadSnapshotLoader,
+ type EnvironmentThreadState,
+} from "./threads.ts";
+
+const TARGET = new PrimaryConnectionTarget({
+ environmentId: EnvironmentId.make("environment-1"),
+ label: "Test environment",
+ httpBaseUrl: "https://environment.example.test",
+ wsBaseUrl: "wss://environment.example.test",
+});
+const THREAD_ID = ThreadId.make("thread-1");
+const PREPARED: PreparedConnection = {
+ environmentId: TARGET.environmentId,
+ label: TARGET.label,
+ httpBaseUrl: TARGET.httpBaseUrl,
+ socketUrl: TARGET.wsBaseUrl,
+ httpAuthorization: null,
+ target: TARGET,
+};
+
+function message(id: string, turnId: string, createdAt: string): OrchestrationMessage {
+ return {
+ id: id as OrchestrationMessage["id"],
+ role: "assistant",
+ text: `text of ${id}`,
+ turnId: TurnId.make(turnId),
+ streaming: false,
+ createdAt,
+ updatedAt: createdAt,
+ };
+}
+
+const OLDER_MESSAGE = message("message-old", "turn-1", "2026-04-01T00:00:00.000Z");
+const RECENT_MESSAGE = message("message-recent", "turn-2", "2026-04-01T01:00:00.000Z");
+
+// Reverts retain turns via checkpoints with checkpointTurnCount <= the revert's
+// turnCount, so both fixture turns carry one: reverting to turnCount 1 keeps
+// turn-1 (the older page's turn) and discards turn-2 (the loaded window's).
+function checkpoint(turnId: string, turnCount: number): OrchestrationThread["checkpoints"][number] {
+ return {
+ turnId: TurnId.make(turnId),
+ checkpointTurnCount: turnCount,
+ checkpointRef:
+ `checkpoint-${turnCount}` as OrchestrationThread["checkpoints"][number]["checkpointRef"],
+ status: "ready",
+ files: [],
+ assistantMessageId: null,
+ completedAt: "2026-04-01T01:00:00.000Z",
+ };
+}
+
+const BASE_THREAD: OrchestrationThread = {
+ id: THREAD_ID,
+ projectId: ProjectId.make("project-1"),
+ title: "Windowed thread",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: "main",
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: "2026-04-01T00:00:00.000Z",
+ updatedAt: "2026-04-01T00:00:00.000Z",
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ deletedAt: null,
+ messages: [RECENT_MESSAGE],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [checkpoint("turn-2", 2)],
+ session: null,
+};
+
+const WINDOWED_SNAPSHOT: OrchestrationThreadDetailSnapshot = {
+ snapshotSequence: 10,
+ thread: BASE_THREAD,
+ page: { beforeCursor: "cursor-1", hasMore: true, snapshotSequence: 10 },
+};
+
+const OLDER_PAGE: OrchestrationThreadDetailSnapshot = {
+ snapshotSequence: 10,
+ thread: {
+ ...BASE_THREAD,
+ messages: [OLDER_MESSAGE],
+ checkpoints: [checkpoint("turn-1", 1)],
+ },
+ page: { beforeCursor: null, hasMore: false, snapshotSequence: 10 },
+};
+
+type LoaderResponse = Option.Option;
+
+const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: {
+ readonly paginationCapability?: boolean;
+ readonly initialResponse?: LoaderResponse;
+ /** Cached snapshot returned by the cache store (simulates a warm cache). */
+ readonly cached?: OrchestrationThreadDetailSnapshot;
+}) {
+ const inputs = yield* Queue.unbounded();
+ const observed = yield* Queue.unbounded();
+ const loaderWindows = yield* Ref.make>([]);
+ const lastSubscribeInput = yield* Ref.make | undefined>(undefined);
+ const savedThreads = yield* Ref.make>([]);
+ // Older-page responses resolve through deferreds so tests can interleave
+ // live events with an in-flight page fetch.
+ const pendingPageResponses = yield* Queue.unbounded>();
+ const supervisorState = yield* SubscriptionRef.make(
+ AVAILABLE_CONNECTION_STATE,
+ );
+ const client = {
+ [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: Record) =>
+ Stream.unwrap(Ref.set(lastSubscribeInput, input).pipe(Effect.as(Stream.fromQueue(inputs)))),
+ } as unknown as WsRpcProtocolClient;
+ const session: RpcSession.RpcSession = {
+ client,
+ initialConfig: Effect.succeed({
+ threadSnapshotPagination: options?.paginationCapability !== false,
+ } as never),
+ ready: Effect.void,
+ probe: Effect.void,
+ closed: Effect.never,
+ };
+ const supervisorSession = yield* SubscriptionRef.make>(
+ Option.some(session),
+ );
+ const prepared = yield* SubscriptionRef.make>(
+ Option.some(PREPARED),
+ );
+ const snapshotLoader = ThreadSnapshotLoader.of({
+ load: (_prepared, _threadId, window) =>
+ Ref.update(loaderWindows, (current) => [...current, window]).pipe(
+ Effect.andThen(
+ window?.beforeCursor === undefined
+ ? Effect.succeed(
+ options?.initialResponse ?? Option.none(),
+ )
+ : Deferred.make().pipe(
+ Effect.tap((deferred) => Queue.offer(pendingPageResponses, deferred)),
+ Effect.flatMap(Deferred.await),
+ ),
+ ),
+ ),
+ });
+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target: TARGET,
+ state: supervisorState,
+ session: supervisorSession,
+ prepared,
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
+ const cache = Persistence.EnvironmentCacheStore.of({
+ loadShell: () => Effect.succeed(Option.none()),
+ saveShell: () => Effect.void,
+ loadThread: () =>
+ Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()),
+ saveThread: (_environmentId, thread) =>
+ Ref.update(savedThreads, (current) => [...current, thread]),
+ removeThread: () => Effect.void,
+ loadServerConfig: () => Effect.succeed(Option.none()),
+ saveServerConfig: () => Effect.void,
+ loadVcsRefs: () => Effect.succeed(Option.none()),
+ saveVcsRefs: () => Effect.void,
+ removeVcsRefs: () => Effect.void,
+ clearVcsRefs: () => Effect.void,
+ clear: () => Effect.void,
+ });
+ const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
+ Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ Effect.provideService(Persistence.EnvironmentCacheStore, cache),
+ Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
+ );
+ yield* SubscriptionRef.changes(threadState).pipe(
+ Stream.runForEach((state) => Queue.offer(observed, state)),
+ Effect.forkScoped,
+ );
+
+ const awaitState = (predicate: (state: EnvironmentThreadState) => boolean) =>
+ Queue.take(observed).pipe(Effect.repeat({ until: predicate }));
+ const resolveNextPage = (response: LoaderResponse) =>
+ Queue.take(pendingPageResponses).pipe(
+ Effect.flatMap((deferred) => Deferred.succeed(deferred, response)),
+ );
+
+ return {
+ inputs,
+ observed,
+ awaitState,
+ resolveNextPage,
+ loaderWindows,
+ lastSubscribeInput,
+ savedThreads,
+ threadState,
+ };
+});
+
+const hasMessage = (state: EnvironmentThreadState, id: string): boolean =>
+ Option.match(state.data, {
+ onNone: () => false,
+ onSome: (thread) => thread.messages.some((entry) => entry.id === id),
+ });
+
+const titleEvent = (title: string, sequence: number): OrchestrationThreadStreamItem => ({
+ kind: "event",
+ event: {
+ eventId: EventId.make(`event-title-${sequence}`),
+ sequence,
+ occurredAt: "2026-04-01T01:30:00.000Z",
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ aggregateKind: "thread",
+ aggregateId: THREAD_ID,
+ type: "thread.meta-updated",
+ payload: {
+ threadId: THREAD_ID,
+ title,
+ updatedAt: "2026-04-01T01:30:00.000Z",
+ },
+ },
+});
+
+// Reverting to turnCount 1 retains only turns whose checkpoint count is <= 1:
+// turn-1 survives, turn-2 (the loaded window's newest turn) is discarded.
+const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({
+ kind: "event",
+ event: {
+ eventId: EventId.make(`event-revert-${sequence}`),
+ sequence,
+ occurredAt: "2026-04-01T02:00:00.000Z",
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ aggregateKind: "thread",
+ aggregateId: THREAD_ID,
+ type: "thread.reverted",
+ payload: {
+ threadId: THREAD_ID,
+ turnCount: 1,
+ },
+ },
+});
+
+describe("thread pagination state", () => {
+ it.effect("windows the initial load when the server advertises pagination", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ const state = yield* harness.awaitState((value) => Option.isSome(value.page));
+ expect(Option.getOrThrow(state.page)).toEqual({
+ beforeCursor: "cursor-1",
+ hasMore: true,
+ loadingOlder: false,
+ });
+ const windows = yield* Ref.get(harness.loaderWindows);
+ expect(windows[0]?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT);
+ const subscribeInput = yield* Ref.get(harness.lastSubscribeInput);
+ expect(subscribeInput?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT);
+ }),
+ );
+
+ it.effect("does not send a window to servers without the capability", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ paginationCapability: false,
+ initialResponse: Option.some({ snapshotSequence: 10, thread: BASE_THREAD }),
+ });
+ const state = yield* harness.awaitState((value) => Option.isSome(value.data));
+ expect(Option.isNone(state.page)).toBe(true);
+ const windows = yield* Ref.get(harness.loaderWindows);
+ expect(windows[0]).toBeUndefined();
+ const subscribeInput = yield* Ref.get(harness.lastSubscribeInput);
+ expect(subscribeInput?.turnLimit).toBeUndefined();
+ }),
+ );
+
+ it.effect("merges an older page below the loaded window and clears the cursor", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ yield* harness.resolveNextPage(Option.some(OLDER_PAGE));
+
+ const state = yield* harness.awaitState((value) => hasMessage(value, "message-old"));
+ const thread = Option.getOrThrow(state.data);
+ // Older rows land before the loaded window's rows.
+ expect(thread.messages.map((entry) => entry.id)).toEqual(["message-old", "message-recent"]);
+ expect(Option.getOrThrow(state.page)).toEqual({
+ beforeCursor: null,
+ hasMore: false,
+ loadingOlder: false,
+ });
+ }),
+ );
+
+ it.effect("discards an in-flight older page when a revert rewrites history", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ // Revert lands while the page fetch is in flight and removes turn-2.
+ yield* Queue.offer(harness.inputs, revertEvent(11));
+ yield* harness.awaitState((value) => !hasMessage(value, "message-recent"));
+ yield* harness.resolveNextPage(Option.some(OLDER_PAGE));
+
+ const state = yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }),
+ );
+ // The stale page was dropped: no resurrected rows, cursor unchanged.
+ expect(hasMessage(state, "message-old")).toBe(false);
+ expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1");
+ }),
+ );
+
+ it.effect("discards an in-flight older page when a fresh snapshot replaces the thread", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ yield* Queue.offer(harness.inputs, {
+ kind: "snapshot",
+ snapshot: {
+ snapshotSequence: 20,
+ thread: { ...BASE_THREAD, title: "Replaced thread" },
+ page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 },
+ },
+ });
+ yield* harness.awaitState((value) =>
+ Option.match(value.data, {
+ onNone: () => false,
+ onSome: (thread) => thread.title === "Replaced thread",
+ }),
+ );
+ yield* harness.resolveNextPage(Option.some(OLDER_PAGE));
+
+ const state = yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }),
+ );
+ expect(hasMessage(state, "message-old")).toBe(false);
+ // The replacement snapshot's cursor wins over the discarded page's.
+ expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-2");
+ }),
+ );
+
+ it.effect("discards an older page read from a projection behind the loaded state", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ yield* harness.resolveNextPage(Option.some({ ...OLDER_PAGE, snapshotSequence: 5 }));
+
+ const state = yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }),
+ );
+ expect(hasMessage(state, "message-old")).toBe(false);
+ expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1");
+ }),
+ );
+
+ it.effect("a merged history page never advances the live-event dedupe sequence", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ // The page was captured at a newer projection sequence (12) than the
+ // loaded state (10); merging it must not swallow events 11-12.
+ yield* harness.resolveNextPage(
+ Option.some({
+ ...OLDER_PAGE,
+ snapshotSequence: 12,
+ page: { beforeCursor: null, hasMore: false, snapshotSequence: 12 },
+ }),
+ );
+ yield* harness.awaitState((value) => hasMessage(value, "message-old"));
+
+ // Event at sequence 11 must still apply after the merge: the revert
+ // discards turn-2, so the loaded window's row disappears while the
+ // merged older turn-1 row survives. If the merge had advanced the
+ // dedupe sequence to the page's 12, this event would be swallowed.
+ yield* Queue.offer(harness.inputs, revertEvent(11));
+ const state = yield* harness.awaitState(
+ (value) => !hasMessage(value, "message-recent") && hasMessage(value, "message-old"),
+ );
+ expect(hasMessage(state, "message-old")).toBe(true);
+ }),
+ );
+
+ it.effect("parks a page read ahead of the live state until events catch up", () =>
+ Effect.gen(function* () {
+ // A page whose thread watermark is ahead of the loaded state may
+ // contain streaming content the subscription has not delivered yet
+ // (e.g. an out-of-window subagent turn mid-stream); merging it
+ // immediately and then replaying those deltas would duplicate text.
+ // The page parks until the live state reaches the watermark.
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
+ yield* harness.awaitState((value) =>
+ Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }),
+ );
+ // Page watermark 11 > loaded sequence 10: must park, not merge.
+ yield* harness.resolveNextPage(
+ Option.some({
+ ...OLDER_PAGE,
+ snapshotSequence: 11,
+ page: { beforeCursor: null, hasMore: false, snapshotSequence: 11, threadSequence: 11 },
+ }),
+ );
+
+ // A live event at sequence 11 arrives; only then does the page merge.
+ yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11));
+ const state = yield* harness.awaitState((value) => hasMessage(value, "message-old"));
+ expect(hasMessage(state, "message-recent")).toBe(true);
+ expect(Option.getOrThrow(state.page).loadingOlder).toBe(false);
+ }),
+ );
+
+ it.effect("a revert keeps the page cursor and triggers no refresh fetch", () =>
+ Effect.gen(function* () {
+ // Cursors are an (anchor, turnId) keyset derived from event content, so
+ // they survive the revert projector's row rewrite: the machine keeps
+ // the stored cursor and performs no snapshot re-fetch. The revert
+ // reducer's turn filtering alone handles loaded history.
+ const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
+ yield* harness.awaitState((value) => Option.isSome(value.page));
+
+ yield* Queue.offer(harness.inputs, revertEvent(11));
+ const state = yield* harness.awaitState((value) => !hasMessage(value, "message-recent"));
+
+ expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1");
+ const windows = yield* Ref.get(harness.loaderWindows);
+ // Only the initial load hit the loader — no post-revert refresh fetch.
+ expect(windows.length).toBe(1);
+ }),
+ );
+
+ it.effect("drops a windowed cache when the server lacks the pagination capability", () =>
+ Effect.gen(function* () {
+ // Resuming a windowed cache via afterSequence against a pre-pagination
+ // server would render only the window forever with no way to load the
+ // rest: the machine must discard the cache and take a full snapshot.
+ const fullSnapshot: OrchestrationThreadDetailSnapshot = {
+ snapshotSequence: 20,
+ thread: { ...BASE_THREAD, title: "Full reload" },
+ };
+ const harness = yield* makeHarness({
+ paginationCapability: false,
+ cached: WINDOWED_SNAPSHOT,
+ initialResponse: Option.some(fullSnapshot),
+ });
+
+ const state = yield* harness.awaitState((value) =>
+ Option.match(value.data, {
+ onNone: () => false,
+ onSome: (thread) => thread.title === "Full reload",
+ }),
+ );
+ expect(Option.isNone(state.page)).toBe(true);
+ // The subscription resumed from the fresh full snapshot, not the
+ // discarded windowed cache's watermark, and sent no window fields.
+ const subscribeInput = yield* Ref.get(harness.lastSubscribeInput);
+ expect(subscribeInput?.turnLimit).toBeUndefined();
+ expect(subscribeInput?.afterSequence).toBe(20);
+ }),
+ );
+
+ it.effect("keeps a windowed cache when the server supports pagination", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({ cached: WINDOWED_SNAPSHOT });
+ const state = yield* harness.awaitState((value) => Option.isSome(value.page));
+ expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1");
+ // Wait for the subscription (recorded when the WS method is invoked)
+ // before asserting its input.
+ const subscribeInput = yield* Ref.get(harness.lastSubscribeInput).pipe(
+ Effect.repeat({ until: (input) => input !== undefined }),
+ );
+ expect(subscribeInput?.afterSequence).toBe(10);
+ }),
+ );
+});
diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts
index 06b5428ca58d..4ba5a0e9df18 100644
--- a/packages/client-runtime/src/state/threads.ts
+++ b/packages/client-runtime/src/state/threads.ts
@@ -2,15 +2,18 @@ import {
ORCHESTRATION_WS_METHODS,
type EnvironmentId as EnvironmentIdType,
type OrchestrationThread,
+ type OrchestrationThreadDetailPage,
type OrchestrationThreadDetailSnapshot,
type OrchestrationThreadStreamItem,
type ThreadId as ThreadIdType,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
+import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
+import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { Atom } from "effect/unstable/reactivity";
@@ -21,13 +24,14 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";
import * as ConnectionWakeups from "../connection/wakeups.ts";
import { EnvironmentCacheStore } from "../platform/persistence.ts";
import { subscribeDynamic } from "../rpc/client.ts";
-import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
+import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts";
import { parseThreadKey, threadKey } from "./entities.ts";
import { applyThreadDetailEvent } from "./threadReducer.ts";
import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
import { followStreamInEnvironment } from "./runtime.ts";
import {
EMPTY_ENVIRONMENT_THREAD_STATE,
+ type EnvironmentThreadPageState,
type EnvironmentThreadState,
type EnvironmentThreadStatus,
} from "./threadState.ts";
@@ -36,6 +40,85 @@ function statusWithoutLiveData(data: Option.Option): Enviro
return Option.isSome(data) ? "cached" : "empty";
}
+/**
+ * Turn window sizes for paginated thread loads: the initial page covers the
+ * last 10 user-anchored turns (subagent/fan-out turns ride along), each
+ * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest
+ * observed threads stays around 100K gzipped while median threads load fully.
+ */
+export const INITIAL_THREAD_USER_TURN_LIMIT = 10;
+export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20;
+
+function pageStateFromSnapshot(
+ page: OrchestrationThreadDetailPage | undefined,
+): Option.Option {
+ return page === undefined
+ ? Option.none()
+ : Option.some({
+ beforeCursor: page.beforeCursor,
+ hasMore: page.hasMore,
+ loadingOlder: false,
+ });
+}
+
+interface ThreadOlderTurnRequestRegistry {
+ /**
+ * Registers the live state machine for a thread. Returns the deregistration
+ * cleanup; registration lives exactly as long as the machine's scope, and a
+ * successor machine for the same thread simply replaces the entry.
+ */
+ readonly register: (key: string, handler: () => void) => () => void;
+ readonly request: (key: string) => boolean;
+}
+
+function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry {
+ const handlers = new Map void>();
+ return {
+ register: (key, handler) => {
+ handlers.set(key, handler);
+ return () => {
+ if (handlers.get(key) === handler) {
+ handlers.delete(key);
+ }
+ };
+ },
+ request: (key) => {
+ const handler = handlers.get(key);
+ if (handler === undefined) {
+ return false;
+ }
+ handler();
+ return true;
+ },
+ };
+}
+
+const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry();
+
+/**
+ * Channel from UI actions to the live per-thread state machines. The machines
+ * resolve it from the Effect environment (overridable in tests); the default
+ * instance is shared with the sync `requestOlderThreadTurns` entry point so
+ * the apps get working wiring without providing anything.
+ */
+export class ThreadOlderTurnRequests extends Context.Reference(
+ "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests",
+ { defaultValue: () => defaultOlderTurnRequestRegistry },
+) {}
+
+/**
+ * Asks the live state machine for `threadId` to fetch the next older page.
+ * Returns false when no machine is live or no fetch was started (no cursor,
+ * already loading); callers render from `EnvironmentThreadState.page` and can
+ * treat false as "nothing to do".
+ */
+export function requestOlderThreadTurns(
+ environmentId: EnvironmentIdType,
+ threadId: ThreadIdType,
+): boolean {
+ return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId }));
+}
+
function formatThreadError(cause: Cause.Cause): string {
const error = Cause.squash(cause);
return error instanceof Error && error.message.trim().length > 0
@@ -73,6 +156,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
data: cachedThread,
status: statusWithoutLiveData(cachedThread),
error: Option.none(),
+ // A cached windowed snapshot restores its page cursor so "load earlier"
+ // works while rendering from cache; a cached full snapshot has no page.
+ page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)),
});
// Seed the resume cursor from the cached snapshot so a warm cache can catch up
// via `afterSequence` instead of re-downloading the full thread body.
@@ -80,6 +166,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }),
);
const awaitingCompletion = yield* Ref.make(false);
+ // Bumped whenever loaded history may have been rewritten out from under an
+ // in-flight older-page fetch (snapshot replacement, revert, deletion). A
+ // page response captured under an older epoch is discarded, not merged.
+ const historyEpoch = yield* Ref.make(0);
+ // Serializes stream-item application against older-page staleness checks +
+ // merges. Without it, a revert or snapshot processed between loadOlderTurns'
+ // epoch check and its merge could still slip resurrected history in.
+ const applyLock = yield* Semaphore.make(1);
+ // Whether the connected server accepts windowed reads; set per subscription
+ // from the session config. Gates loadOlderTurns so a reconnect to a
+ // pre-pagination server never sends unsupported window parameters.
+ const paginationSupported = yield* Ref.make(false);
+ // An older page whose thread watermark is ahead of the live state, parked
+ // until the subscription catches up (see mergeOlderPage's caller). At most
+ // one can exist because loadOlderTurns no-ops while loadingOlder is true.
+ const pendingOlderPage = yield* Ref.make<{
+ readonly snapshot: OrchestrationThreadDetailSnapshot;
+ readonly epoch: number;
+ } | null>(null);
const persistence = yield* Queue.sliding(1);
const persist = Effect.fn("EnvironmentThreadState.persist")(function* (
@@ -124,6 +229,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
);
const setDisconnected = Effect.gen(function* () {
yield* Ref.set(awaitingCompletion, false);
+ // The capability belongs to the session that advertised it. During a
+ // reconnect, a new prepared connection can exist before the new session's
+ // config arrives; leaving the old value would let loadOlderTurns send
+ // window parameters to a server that may not accept them (review
+ // finding). makeSubscribeInput re-sets it from the next session's config.
+ yield* Ref.set(paginationSupported, false);
yield* SubscriptionRef.update(state, (current) => ({
...current,
status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
@@ -143,28 +254,51 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
thread: OrchestrationThread,
+ // "keep" preserves the current page state (live events touch only loaded
+ // recent turns); a snapshot or merged page passes its own page state.
+ page: Option.Option | "keep",
) {
const waiting = yield* Ref.get(awaitingCompletion);
- yield* SubscriptionRef.set(state, {
+ yield* SubscriptionRef.update(state, (current) => ({
data: Option.some(thread),
- status: waiting ? "synchronizing" : "live",
+ status: waiting ? ("synchronizing" as const) : ("live" as const),
error: Option.none(),
- });
+ page: page === "keep" ? current.page : page,
+ }));
// Active threads can update many times per second and retain large tool
// payloads. The server remains the source of truth while a turn is active;
// persist once it settles so cache encoding stays off the streaming path.
if (shouldPersistThread(thread)) {
const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
- yield* Queue.offer(persistence, { snapshotSequence, thread });
+ const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page));
+ yield* Queue.offer(persistence, {
+ snapshotSequence,
+ thread,
+ // Persist the window boundary with the window's content so a cache
+ // restore can keep paging from where the loaded history ends.
+ ...Option.match(currentPage, {
+ onNone: () => ({}),
+ onSome: (value) =>
+ ({
+ page: {
+ beforeCursor: value.beforeCursor,
+ hasMore: value.hasMore,
+ snapshotSequence,
+ },
+ }) as const,
+ }),
+ });
}
});
const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
yield* Ref.set(awaitingCompletion, false);
+ yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
yield* SubscriptionRef.set(state, {
data: Option.none(),
status: "deleted",
error: Option.none(),
+ page: Option.none(),
});
yield* cache.removeThread(environmentId, threadId).pipe(
Effect.catch((error) =>
@@ -179,7 +313,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
);
});
- const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* (
+ // Body of applyItem, running under applyLock.
+ const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* (
item: OrchestrationThreadStreamItem,
) {
if (item.kind === "synchronized") {
@@ -193,8 +328,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
}
if (item.kind === "snapshot") {
+ // A fresh snapshot replaces all loaded history, including older
+ // pages: a turn reverted while disconnected would otherwise survive
+ // in the preserved history with no event left to remove it. The
+ // epoch bump discards any older-page fetch racing this snapshot.
+ yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence);
- yield* setThread(item.snapshot.thread);
+ yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page));
return;
}
@@ -211,12 +351,184 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
}
return;
}
+ if (item.event.type === "thread.reverted") {
+ // A revert rewrites loaded history (whole turns disappear), so an
+ // older-page fetch in flight may straddle the removed range; the epoch
+ // bump discards it. The stored page cursor stays valid: cursors are an
+ // (anchor, turnId) keyset derived from event content, which survives
+ // the revert projector's row rewrite, so no refresh is needed — the
+ // revert reducer's turn filtering fully handles loaded history.
+ yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
+ }
const result = applyThreadDetailEvent(current.data.value, item.event);
if (result.kind === "updated") {
- yield* setThread(result.thread);
+ yield* setThread(result.thread, "keep");
} else if (result.kind === "deleted") {
yield* setDeleted();
}
+ // The event may have advanced the live state past a parked page's
+ // watermark; merge it as soon as that happens.
+ yield* tryMergePendingOlderPage();
+ });
+
+ // Merges a parked older page once the live state has caught up to the
+ // page's thread watermark, or discards it if history was rewritten
+ // (epoch advanced) while it waited. Must run under applyLock.
+ const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")(
+ function* () {
+ const pending = yield* Ref.get(pendingOlderPage);
+ if (pending === null) {
+ return;
+ }
+ const epochNow = yield* Ref.get(historyEpoch);
+ if (epochNow !== pending.epoch) {
+ yield* Ref.set(pendingOlderPage, null);
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })),
+ }));
+ return;
+ }
+ const watermark = pending.snapshot.page?.threadSequence;
+ const loadedSequence = yield* SubscriptionRef.get(lastSequence);
+ if (watermark !== undefined && watermark > loadedSequence) {
+ return;
+ }
+ yield* Ref.set(pendingOlderPage, null);
+ yield* mergeOlderPage(pending.snapshot);
+ },
+ );
+
+ const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* (
+ item: OrchestrationThreadStreamItem,
+ ) {
+ yield* applyLock.withPermits(1)(applyItemLocked(item));
+ });
+
+ // Merges an older disjoint page below the currently loaded window. All four
+ // windowed collections prepend; identity dedupe guards the (server-bug or
+ // cursor-misuse) case of overlapping pages so a row never renders twice.
+ const mergeOlderPage = Effect.fn("EnvironmentThreadState.mergeOlderPage")(function* (
+ snapshot: OrchestrationThreadDetailSnapshot,
+ ) {
+ // The merge is built inside the update callback so it composes with
+ // whatever thread value is current at commit time. The applyLock already
+ // serializes this against event application; the atomic build is defense
+ // in depth against future callers outside the lock.
+ let merged: OrchestrationThread | null = null;
+ yield* SubscriptionRef.update(state, (value) => {
+ if (Option.isNone(value.data)) {
+ return value;
+ }
+ const loaded = value.data.value;
+ const older = snapshot.thread;
+ const mergeById = (
+ olderRows: ReadonlyArray,
+ loadedRows: ReadonlyArray,
+ ): ReadonlyArray => {
+ const seen = new Set(loadedRows.map((row) => row.id));
+ return [...olderRows.filter((row) => !seen.has(row.id)), ...loadedRows];
+ };
+ const seenCheckpoints = new Set(loaded.checkpoints.map((row) => row.turnId));
+ merged = {
+ // Thread metadata stays the loaded (newer) snapshot's; only the
+ // windowed collections gain rows from the older page.
+ ...loaded,
+ messages: mergeById(older.messages, loaded.messages),
+ activities: mergeById(older.activities, loaded.activities),
+ proposedPlans: mergeById(older.proposedPlans, loaded.proposedPlans),
+ checkpoints: [
+ ...older.checkpoints.filter((row) => !seenCheckpoints.has(row.turnId)),
+ ...loaded.checkpoints,
+ ],
+ };
+ return {
+ ...value,
+ data: Option.some(merged),
+ page: pageStateFromSnapshot(snapshot.page),
+ };
+ });
+ // Persist the widened window under the *loaded* watermark: the merged
+ // content is only known consistent with the state it merged into, not
+ // with the page's own (possibly newer) sequence.
+ if (merged !== null && shouldPersistThread(merged)) {
+ const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
+ yield* Queue.offer(persistence, {
+ snapshotSequence,
+ thread: merged,
+ ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }),
+ });
+ }
+ });
+
+ const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () {
+ // Gated on the connected server's capability: a reconnect to a
+ // pre-pagination server must never receive window parameters.
+ if (!(yield* Ref.get(paginationSupported))) {
+ return;
+ }
+ const current = yield* SubscriptionRef.get(state);
+ const page = Option.getOrNull(current.page);
+ if (page === null || page.loadingOlder || !page.hasMore || page.beforeCursor === null) {
+ return;
+ }
+ const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared));
+ if (prepared === null) {
+ return;
+ }
+ const epochAtStart = yield* Ref.get(historyEpoch);
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: true })),
+ }));
+ const window: ThreadSnapshotWindow = {
+ turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT,
+ beforeCursor: page.beforeCursor,
+ };
+ const response = yield* snapshotLoader.load(prepared, threadId, window);
+ // Staleness check and merge run under the same lock as stream-item
+ // application, so a revert/snapshot cannot land between them (TOCTOU
+ // review finding) — anything that rewrites history bumps the epoch
+ // before this permit is acquired.
+ yield* applyLock.withPermits(1)(
+ Effect.gen(function* () {
+ const epochNow = yield* Ref.get(historyEpoch);
+ const loadedSequence = yield* SubscriptionRef.get(lastSequence);
+ // A page carrying a sequence older than the loaded state was read
+ // from a projection behind what we render; merging it could
+ // resurrect turns a newer snapshot or revert already removed.
+ const stale =
+ epochNow !== epochAtStart ||
+ Option.match(response, {
+ onNone: () => false,
+ onSome: (snapshot) => snapshot.snapshotSequence < loadedSequence,
+ });
+ if (Option.isNone(response) || stale) {
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })),
+ }));
+ return;
+ }
+ // A page read AHEAD of the live state may include content (e.g.
+ // streaming deltas of an out-of-window turn) the subscription has
+ // not delivered yet; merging now and then replaying those events
+ // would duplicate them. Park the page until the live state reaches
+ // the page's thread-scoped watermark; loadingOlder stays true so
+ // the UI shows progress and no second fetch starts. Pages from
+ // pre-watermark servers (threadSequence absent) merge immediately,
+ // preserving the old behavior.
+ const watermark = response.value.page?.threadSequence;
+ if (watermark !== undefined && watermark > loadedSequence) {
+ yield* Ref.set(pendingOlderPage, {
+ snapshot: response.value,
+ epoch: epochNow,
+ });
+ return;
+ }
+ yield* mergeOlderPage(response.value);
+ }),
+ );
});
yield* SubscriptionRef.changes(supervisor.state).pipe(
@@ -244,14 +556,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
subscribeDynamic(
ORCHESTRATION_WS_METHODS.subscribeThread,
Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {
- const supportsCompletionMarker = yield* session.initialConfig.pipe(
- Effect.map((config) => config.threadResumeCompletionMarker === true),
- Effect.orElseSucceed(() => false),
+ const config = yield* session.initialConfig.pipe(
+ Effect.orElseSucceed(
+ () =>
+ ({}) as {
+ threadResumeCompletionMarker?: boolean;
+ threadSnapshotPagination?: boolean;
+ },
+ ),
);
+ const supportsCompletionMarker = config.threadResumeCompletionMarker === true;
+ // Windowed loads are gated on the server capability: pre-pagination
+ // servers reject unknown query params, and a windowed WS fallback to
+ // such a server would silently hide history.
+ const supportsPagination = config.threadSnapshotPagination === true;
+ yield* Ref.set(paginationSupported, supportsPagination);
yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
yield* setSynchronizing;
let current = yield* SubscriptionRef.get(state);
+ // A windowed cache resuming against a server without pagination is a
+ // trap: afterSequence resume keeps only the window, and the missing
+ // older turns can never be loaded (the server has no cursor reads).
+ // Drop the window marker and treat the data as needing a full reload.
+ if (!supportsPagination && Option.isSome(current.page)) {
+ yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ data: Option.none(),
+ status: value.status === "deleted" ? value.status : ("empty" as const),
+ page: Option.none(),
+ }));
+ yield* SubscriptionRef.set(lastSequence, 0);
+ current = yield* SubscriptionRef.get(state);
+ }
if (Option.isNone(current.data) && current.status !== "deleted") {
const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
Effect.flatMap(
@@ -267,7 +605,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
}),
),
);
- const httpSnapshot = yield* snapshotLoader.load(prepared, threadId);
+ const httpSnapshot = yield* snapshotLoader.load(
+ prepared,
+ threadId,
+ supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined,
+ );
if (Option.isSome(httpSnapshot)) {
yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
current = yield* SubscriptionRef.get(state);
@@ -288,6 +630,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
threadId,
...(canResume ? { afterSequence: sequence } : {}),
...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
+ // The WS fallback snapshot (sent when afterSequence is missing or
+ // the gap is too large) should be windowed the same as the HTTP
+ // path; without this a resume failure re-downloads the full thread.
+ ...(supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : {}),
};
}),
{
@@ -298,13 +644,47 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
).pipe(Stream.runForEach(applyItem)),
);
+ // Expose loadOlderTurns to UI actions through the request registry.
+ // Requests funnel through a sliding queue drained serially, so mashing
+ // "load earlier" coalesces (loadOlderTurns itself no-ops while a fetch is
+ // in flight).
+ const olderTurnRequestRegistry = yield* ThreadOlderTurnRequests;
+ const olderTurnRequests = yield* Queue.sliding(1);
+ yield* Stream.fromQueue(olderTurnRequests).pipe(
+ Stream.runForEach(() => loadOlderTurns()),
+ Effect.forkScoped,
+ );
+ const deregister = olderTurnRequestRegistry.register(
+ threadKey({ environmentId, threadId }),
+ () => {
+ Queue.offerUnsafe(olderTurnRequests, undefined);
+ },
+ );
+ yield* Effect.addFinalizer(() => Effect.sync(deregister));
+
yield* Effect.addFinalizer(() =>
Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe(
Effect.flatMap(([current, snapshotSequence]) =>
Option.match(current.data, {
onNone: () => Effect.void,
onSome: (thread) =>
- shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void,
+ shouldPersistThread(thread)
+ ? persist({
+ snapshotSequence,
+ thread,
+ ...Option.match(current.page, {
+ onNone: () => ({}),
+ onSome: (page) =>
+ ({
+ page: {
+ beforeCursor: page.beforeCursor,
+ hasMore: page.hasMore,
+ snapshotSequence,
+ },
+ }) as const,
+ }),
+ })
+ : Effect.void,
}),
),
),
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index 2d40dad60cc4..f385a2eff2c9 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -457,6 +457,16 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
threadId: ThreadId,
});
+// Query-string window for windowed thread snapshots (GET payloads must encode
+// to strings). Both fields optional: omitting them keeps the full-snapshot
+// behavior, so pagination stays opt-in per request.
+const EnvironmentOrchestrationThreadSnapshotQuery = {
+ turnLimit: Schema.optional(
+ Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
+ ),
+ beforeCursor: Schema.optional(TrimmedNonEmptyString),
+};
+
export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
.add(
HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {
@@ -476,6 +486,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", {
headers: OptionalBearerHeaders,
params: EnvironmentOrchestrationThreadSnapshotParams,
+ payload: EnvironmentOrchestrationThreadSnapshotQuery,
success: OrchestrationThreadDetailSnapshot,
error: EnvironmentOrchestrationThreadSnapshotErrors,
}).middleware(EnvironmentAuthenticatedAuth),
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 7a8fc657fb2e..c753d53be303 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -12,6 +12,7 @@ import {
IsoDateTime,
MessageId,
NonNegativeInt,
+ PositiveInt,
ProjectId,
ProviderItemId,
ThreadId,
@@ -465,6 +466,20 @@ export const OrchestrationThreadShell = Schema.Struct({
* live work. Optional so old servers/clients interop; absent = none.
*/
backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))),
+ /**
+ * Current plan step while a turn runs, for the Working indicators
+ * (sidebar row, in-chat working line). Cleared when the turn settles —
+ * never persists as stale UI. Optional so old servers/clients interop.
+ */
+ planProgress: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ step: TrimmedNonEmptyString,
+ completedSteps: NonNegativeInt,
+ totalSteps: NonNegativeInt,
+ }),
+ ),
+ ),
});
export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
@@ -545,12 +560,62 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({
* snapshot or catch-up replay and before it begins emitting live events.
*/
requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
+ /**
+ * When provided, the fallback snapshot frame (sent when `afterSequence` is
+ * missing or the catch-up gap is too large) is windowed to the last
+ * `turnLimit` user-anchored turns and carries `page` metadata. Absent means
+ * the fallback snapshot is the full thread, preserving pre-pagination client
+ * behavior. Live events are unaffected either way.
+ */
+ turnLimit: Schema.optionalKey(PositiveInt),
});
export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
+/**
+ * Bounds a thread detail read to a window of recent turns. `turnLimit` counts
+ * turns with a user pending message (subagent/fan-out turns between them ride
+ * along), so the window always contains the last N user prompts. `beforeCursor`
+ * requests the disjoint page of older turns strictly before a previously
+ * returned cursor. Requests without a window get the full thread; pagination is
+ * strictly opt-in so older clients keep today's behavior on both HTTP and the
+ * WebSocket fallback snapshot.
+ */
+export const OrchestrationThreadDetailWindow = Schema.Struct({
+ turnLimit: Schema.optionalKey(PositiveInt),
+ beforeCursor: Schema.optionalKey(TrimmedNonEmptyString),
+});
+export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type;
+
+/**
+ * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and
+ * exclusive: passing it back returns the adjacent disjoint slice of older
+ * turns. `null` means the thread is fully loaded below this page. The
+ * `snapshotSequence` mirrors the top-level snapshot sequence so history pages
+ * can be sequence-checked against live state before merging.
+ */
+export const OrchestrationThreadDetailPage = Schema.Struct({
+ beforeCursor: Schema.NullOr(TrimmedNonEmptyString),
+ hasMore: Schema.Boolean,
+ snapshotSequence: NonNegativeInt,
+ /**
+ * Highest event sequence applied to THIS thread at page read time. The
+ * global `snapshotSequence` advances with every thread's events, so a
+ * client cannot wait for it via its per-thread subscription; this
+ * thread-scoped watermark is reachable. A client merging an older page
+ * must first have applied live events up to it — otherwise a streaming
+ * turn outside the loaded window could have deltas replayed on top of
+ * page content that already includes them, duplicating text.
+ */
+ threadSequence: Schema.optionalKey(NonNegativeInt),
+});
+export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type;
+
export const OrchestrationThreadDetailSnapshot = Schema.Struct({
snapshotSequence: NonNegativeInt,
thread: OrchestrationThread,
+ // Present only on windowed responses. Absent on full snapshots (and from
+ // pre-pagination servers), which clients treat as fully loaded.
+ page: Schema.optional(OrchestrationThreadDetailPage),
});
export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type;
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 20b40dffa755..d7bc4c5c1898 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -434,6 +434,12 @@ export const ServerConfig = Schema.Struct({
shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
/** Whether thread subscriptions can emit an opt-in catch-up completion marker. */
threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
+ /**
+ * Whether thread detail reads accept a turn window (`turnLimit`/
+ * `beforeCursor`) and return `page` metadata. Clients must not send window
+ * fields to servers that don't advertise this.
+ */
+ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
});
export type ServerConfig = typeof ServerConfig.Type;
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 9a020ce1bbda..f5d50e9f3472 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))
export type FontFamilyPreference = typeof FontFamilyPreference.Type;
export const ClientSettingsSchema = Schema.Struct({
- autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
@@ -168,6 +167,10 @@ export const ClientSettingsSchema = Schema.Struct({
modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
}),
).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ // Legacy plan mode. The composer's Build/Plan toggle was removed from the
+ // default UI; this beta flag restores it (plus the /plan and /default slash
+ // commands) for users who still rely on the old workflow.
+ planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
@@ -944,7 +947,6 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
export const ClientSettingsPatch = Schema.Struct({
- autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
@@ -980,6 +982,7 @@ export const ClientSettingsPatch = Schema.Struct({
}),
),
),
+ planModeEnabled: Schema.optionalKey(Schema.Boolean),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts
index 4bd1070bf1f1..c58395393d37 100644
--- a/packages/shared/src/observability.test.ts
+++ b/packages/shared/src/observability.test.ts
@@ -21,6 +21,7 @@ import {
makeTraceSink,
type TraceRecord,
type TraceSinkFlushStats,
+ truncateTraceAttributes,
} from "./observability.ts";
describe("errorTag", () => {
@@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) =>
const nodeServicesIt = it.layer(NodeServices.layer);
+describe("truncateTraceAttributes", () => {
+ it("clamps oversized strings at any depth without mutating the input", () => {
+ const stack = "s".repeat(2_000);
+ const attributes = {
+ "db.query.text": "q".repeat(2_000),
+ short: "ok",
+ error: { name: "Error", stack, nested: ["a".repeat(2_000)] },
+ };
+ const truncated = truncateTraceAttributes(attributes);
+
+ assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length);
+ assert.equal(truncated["short"], "ok");
+ const error = truncated["error"] as { stack: string; nested: Array };
+ assert.equal(error.stack.length, 500 + "…[truncated]".length);
+ assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length);
+ // Input is untouched: the live span's attributes are shared.
+ assert.equal(attributes.error.stack, stack);
+ });
+
+ it("returns the same reference when nothing exceeds the limits", () => {
+ const attributes = { short: "ok", nested: { fine: "also ok" } };
+ assert.equal(truncateTraceAttributes(attributes), attributes);
+ });
+});
+
describe("observability", () => {
it("normalizes circular arrays, maps, and sets without recursing forever", () => {
const array: Array = ["alpha"];
diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts
index e0a7595865d9..67057c548806 100644
--- a/packages/shared/src/observability.ts
+++ b/packages/shared/src/observability.ts
@@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord["
};
}
+const TRACE_ATTRIBUTE_MAX_LENGTH = 500;
+const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200;
+const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]";
+const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]);
+
+// Clamps strings nested inside already-normalized attribute values (arrays and
+// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the
+// input reference when nothing was clamped.
+function truncateNestedValue(value: unknown): unknown {
+ if (typeof value === "string") {
+ return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH
+ ? value
+ : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`;
+ }
+ if (Array.isArray(value)) {
+ const truncated = value.map(truncateNestedValue);
+ return truncated.some((entry, index) => entry !== value[index]) ? truncated : value;
+ }
+ if (isPlainObject(value)) {
+ let truncated: Record | undefined;
+ for (const [key, entry] of Object.entries(value)) {
+ const next = truncateNestedValue(entry);
+ if (next === entry) continue;
+ truncated ??= { ...value };
+ truncated[key] = next;
+ }
+ return truncated ?? value;
+ }
+ return value;
+}
+
+/**
+ * Clamps oversized attribute values on the serialized trace record so the file
+ * sink stays small, including strings nested inside arrays and objects (e.g.
+ * error stacks). Returns a new record when anything was clamped; never
+ * mutates the input (the live span's attributes are shared with other tracers).
+ */
+export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes {
+ let truncated: Record | undefined;
+ for (const [key, value] of Object.entries(attributes)) {
+ if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) {
+ if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue;
+ truncated ??= { ...attributes };
+ truncated[key] =
+ `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`;
+ continue;
+ }
+ const next = truncateNestedValue(value);
+ if (next === value) continue;
+ truncated ??= { ...attributes };
+ truncated[key] = next;
+ }
+ return truncated ?? attributes;
+}
+
export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
const status = span.status as Extract;
const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
@@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
startTimeUnixNano: String(status.startTime),
endTimeUnixNano: String(status.endTime),
durationMs: Number(status.endTime - status.startTime) / 1_000_000,
- attributes: compactTraceAttributes(Object.fromEntries(span.attributes)),
+ attributes: truncateTraceAttributes(
+ compactTraceAttributes(Object.fromEntries(span.attributes)),
+ ),
events: span.events.map(([name, startTime, attributes]) => ({
name,
timeUnixNano: String(startTime),
- attributes: compactTraceAttributes(attributes),
+ attributes: truncateTraceAttributes(compactTraceAttributes(attributes)),
})),
links: span.links.map((link) => ({
traceId: link.span.traceId,
spanId: link.span.spanId,
- attributes: compactTraceAttributes(link.attributes),
+ attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)),
})),
exit: formatTraceExit(status.exit),
};
diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts
index 4888192098e1..756f8bd2d30d 100644
--- a/packages/shared/src/shell.ts
+++ b/packages/shared/src/shell.ts
@@ -3,6 +3,7 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
+import * as Clock from "effect/Clock";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
@@ -499,6 +500,54 @@ function resolveCommandCandidates(
return Array.from(new Set(candidates));
}
+// Session bootstrap resolves the same commands over and over, each PATH scan
+// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of
+// thousands per connect). Memoize the scan outcome per
+// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the
+// cache while any change to the search environment invalidates immediately.
+// Explicit-path resolution is never cached - callers probe paths they have
+// just written (e.g. managed binary installs). A "not-found" outcome is also
+// cached for the TTL, so a just-installed binary can stay invisible for up to
+// 30s unless resolved by explicit path.
+// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward
+// wall-clock adjustments cannot keep expired entries alive.
+const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n;
+const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512;
+const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0);
+
+interface CommandResolutionCacheEntry {
+ readonly resolvedPath: string | null;
+ readonly expiresAtNanos: bigint;
+}
+
+// The cache lives in the Effect environment (like HostProcessPlatform above)
+// so tests and embedders can provide an isolated instance; the default is a
+// single process-wide map shared by all consumers.
+export const CommandResolutionCache = Context.Reference