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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,46 @@ it.effect("a read-only client reads threads and is refused every write before it
);
const dispatched: Array<string> = [];

it.effect("an agent turns a thread's auto-settle off and back on", () => {
const commands: Array<unknown> = [];
return Effect.gen(function* () {
const server = yield* McpServer.McpServer;
for (const action of ["disable_auto_settle", "enable_auto_settle"]) {
const result = yield* server
.callTool({ name: "t3_thread_organize", arguments: { action } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(result.isError).toBe(false);
}
expect(commands).toMatchObject([
{ type: "thread.auto-settle.set", threadId, enabled: false },
{ type: "thread.auto-settle.set", threadId, enabled: true },
]);
}).pipe(
Effect.provide(
layerThreadToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(NodeCrypto.layer),
Layer.provide(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
getProjectThreadRecords: ({ threadId: id }) =>
Effect.succeed(
McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)),
),
dispatch: (command) => {
commands.push(command);
return Effect.succeed({ sequence: 1 } as never);
},
}),
),
),
),
);
});

it.effect("refuses act-as-caller tools to a client caller", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
case "mark_unread":
command = { ...common, type: "thread.mark-unread" };
break;
case "disable_auto_settle":
case "enable_auto_settle":
command = {
...common,
type: "thread.auto-settle.set",
enabled: input.action === "enable_auto_settle",
};
break;
default:
command = { ...common, type: `thread.${input.action}` };
}
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";

const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
description:
"Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.",
"Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active. `disable_auto_settle` stops the thread settling itself, such as after its PR merges, until `enable_auto_settle`.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId),
Expand All @@ -45,6 +45,8 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
"archive",
"unarchive",
"mark_unread",
"disable_auto_settle",
"enable_auto_settle",
]),
snoozedUntil: Schema.optional(IsoDateTime),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,47 @@ const layerTest = Layer.mergeAll(
),
);

it.effect("a same-millisecond auto-settle disable rejects an in-flight settlement", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const threadId = ThreadId.make("thread:auto-settle-disable-race");
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("create-auto-settle-disable-race"),
threadId,
projectId: ProjectId.make("project:auto-settle-disable-race"),
title: "Follow-up work",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdBy: "user",
creationSource: "web",
});
const snapshotAt = (yield* projections.getThread(threadId)).updatedAt;
yield* orchestrator.dispatch({
type: "thread.auto-settle.set",
commandId: CommandId.make("disable-auto-settle-race"),
threadId,
enabled: false,
});
const disabled = yield* projections.getThread(threadId);
assert.equal(DateTime.toEpochMillis(disabled.updatedAt), DateTime.toEpochMillis(snapshotAt));
const settlement = yield* Effect.exit(
orchestrator.dispatch({
type: "thread.auto-settle",
commandId: CommandId.make("in-flight-auto-settle-race"),
threadId,
snapshotAt,
}),
);
assert.equal(settlement._tag, "Failure");
assert.equal((yield* projections.getThread(threadId)).settledOverride, null);
}).pipe(Effect.provide(layerTest)),
);

it.effect(
"records approval feedback and targets only its runtime request without dispatching a parent turn",
() =>
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12873,6 +12873,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
);
if (
thread.settledOverride !== null ||
thread.autoSettleDisabledAt != null ||
DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt)
) {
return yield* new OrchestratorDispatchError({
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5129,
"codexAdditionalContextBytes": 6859,
"mcpToolListJsonBytes": 46109
"mcpToolListJsonBytes": 46265
}
3 changes: 2 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ finished background work or a pull request watch starts on its own does not.
To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,
choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick
**Enabled** to return to the usual rules. Manual settle, snooze, and archive still work while it
is disabled.
is disabled. An agent can turn this off and on too, for example to keep a thread whose pull request
merged open while it finishes follow-up work.

Change these rules in **Settings → Threads**.
They continue to run when your apps are closed. On web and desktop, choose an environment at the
Expand Down
2 changes: 2 additions & 0 deletions evals/mcp-tool-use/cases/orchestration.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@
{"id": "two-children-same-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for two Sol xhigh subagents, an Astra parent started one and refused the second, citing 'One child per named model' (#24967).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}}, "prompt": "Launch two Sol xhigh subagents. Give each a trivial task that only replies OK.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "target.options.reasoningEffort": "xhigh"}}], "minCalls": {"delegate_task": 2}, "callsMustNot": [{"tool": "delegate_task", "args": {"target.model": "~muse"}}, {"tool": "delegate_task", "args": {"target.model": "~claude"}}]}, "notes": "From #24967. One child plus the parent doing the work fails, and so does a second model in place of Sol."}
{"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."}
{"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}}
{"id": "merged-thread-followup-disables-auto-settle", "area": "orchestration", "kind": "bad", "pattern": "t3_thread_organize unsettle used to hold a thread open for post-merge work", "experience": "After a thread's PR merged, an orchestrating agent kept waking it for production checks. Each run ended with the thread auto-settled again, and unsettle did not hold, so it settled 5 times in a day.", "rule": "t3_thread_organize: '`disable_auto_settle` stops the thread settling itself, such as after its PR merges, until `enable_auto_settle`.'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Start a new thread to fix the CSV export timeout on large workspaces and have it open a PR."}, {"role": "assistant", "text": "Started [Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555). It opened https://github.com/acme/app/pull/418.", "toolCalls": [{"tool": "t3_thread_launch", "input": {"title": "Fix CSV export timeout", "message": "The CSV export times out on workspaces with more than 50k rows. Fix it and open a PR."}, "result": {"threadId": "55555555-5555-4555-8555-555555555555", "projectId": "22222222-2222-4222-8222-222222222222", "status": "starting", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)"}}]}], "mocks": {"t3_thread_send": {"threadId": "55555555-5555-4555-8555-555555555555", "messageId": "msg-9", "runId": "run-9", "queued": false}, "t3_thread_organize": {"sequence": 57}, "t3_thread_read": {"thread": {"threadId": "55555555-5555-4555-8555-555555555555", "title": "Fix CSV export timeout", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)", "status": "idle"}, "items": [{"position": 30, "createdBy": "assistant", "text": "PR https://github.com/acme/app/pull/418 merged. The export now streams rows in pages of 5,000."}], "nextPosition": 31, "hasMore": false}}, "shellMocks": [{"match": ".*", "output": ""}]}, "prompt": "418 merged. That thread still has to check the export in production after tonight's deploy, but it keeps settling itself every time one of its runs ends. Tell it to do the production check, and keep it from settling until that's done.", "expect": {"calls": [{"tool": "t3_thread_organize", "args": {"threadId": "55555555-5555-4555-8555-555555555555", "action": "disable_auto_settle"}}, {"tool": "t3_thread_send", "args": {"threadId": "55555555-5555-4555-8555-555555555555"}}]}, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/26097"}, "notes": "Before the fix, t3_thread_organize had no per-thread auto-settle switch, so the agent could only unsettle, which the next run's activity overrides."}
{"id": "control-merged-thread-followup-leaves-auto-settle", "area": "orchestration", "kind": "control", "pattern": "plain follow-up to a merged thread", "experience": "Ordinary follow-up to a thread whose PR merged. Nothing asks to keep it open, so the agent should only send the message.", "rule": "Keeps disable_auto_settle from becoming a reflex on every post-merge message.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Start a new thread to fix the CSV export timeout on large workspaces and have it open a PR."}, {"role": "assistant", "text": "Started [Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555). It opened https://github.com/acme/app/pull/418.", "toolCalls": [{"tool": "t3_thread_launch", "input": {"title": "Fix CSV export timeout", "message": "The CSV export times out on workspaces with more than 50k rows. Fix it and open a PR."}, "result": {"threadId": "55555555-5555-4555-8555-555555555555", "projectId": "22222222-2222-4222-8222-222222222222", "status": "starting", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)"}}]}], "mocks": {"t3_thread_send": {"threadId": "55555555-5555-4555-8555-555555555555", "messageId": "msg-9", "runId": "run-9", "queued": false}, "t3_thread_organize": {"sequence": 57}, "t3_thread_read": {"thread": {"threadId": "55555555-5555-4555-8555-555555555555", "title": "Fix CSV export timeout", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)", "status": "idle"}, "items": [{"position": 30, "createdBy": "assistant", "text": "PR https://github.com/acme/app/pull/418 merged. The export now streams rows in pages of 5,000."}], "nextPosition": 31, "hasMore": false}}, "shellMocks": [{"match": ".*", "output": ""}]}, "prompt": "418 merged. Ask that thread to delete its feature branch.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "55555555-5555-4555-8555-555555555555"}}], "callsMustNot": [{"tool": "t3_thread_organize", "args": {"action": "disable_auto_settle"}}]}, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/26097"}}
Loading