diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 81614e5eb4274..4b1cbbf13abaf 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -964,6 +964,46 @@ it.effect("a read-only client reads threads and is refused every write before it ); const dispatched: Array = []; +it.effect("an agent turns a thread's auto-settle off and back on", () => { + const commands: Array = []; + 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(McpHttpServer.layerInProcessMcpServer), + 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("hides act-as-caller tools from a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 972174638e10f..8f12d7fae28d9 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -394,6 +394,14 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { command = { ...common, type: "thread.pin", ...(orderKey ? { orderKey } : {}) }; 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}` }; } diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index d6c9a34565ddb..4b778f987c350 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -32,7 +32,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), @@ -46,6 +46,8 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "archive", "unarchive", "mark_unread", + "disable_auto_settle", + "enable_auto_settle", ]), snoozedUntil: Schema.optional(IsoDateTime), }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 6ff5137556242..e796a9cb5c7a6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -76,6 +76,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.each( (["provider.switch", "thread.model-selection.set"] as const).flatMap((type) => [ diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5257330e2cb5d..3201356afcd79 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -14968,6 +14968,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({ diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 0051813371e5b..0bace50234d27 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 49736 + "mcpToolListJsonBytes": 49892 } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 905f3a5bfbe9c..6abbff7dbd932 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -200,7 +200,9 @@ To keep one thread out of the settled shelf no matter how long it sits idle, ope choose **Auto-settle behavior**, and pick **Never**. The current option is checked. Pick **Follow settings** to return to the rules in **Settings → Threads**. While those rules are off, a thread on **Follow settings** does not settle automatically either. Manual settle, snooze, and -archive still work while it is set to **Never**. +archive still work while it is set to **Never**. An agent can switch a thread between **Never** and +**Follow settings** 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 diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index df13d9cd6313b..0e0456325132c 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -45,3 +45,5 @@ {"id": "settle-after-merge-on-wake", "area": "orchestration", "kind": "bad", "pattern": "settle read as plain English for finish", "experience": "Asked to merge a PR once its checks pass and then settle this thread, the agent merged on the checks-passed wake and ended the turn without calling t3_thread_organize (#26749).", "rule": "Asked to settle ... a thread, including this one: call t3_thread_organize.", "privacy": "commit-safe", "notes": "From #26749's delayed pattern. Wake text is copied from pullRequestWatch.ts. Good: merge #123, then settle this thread on the same turn.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Okay this looks good to me. Any risk merging it?"}, {"role": "assistant", "text": "Low risk. [#123](https://github.com/acme/app/pull/123) only adds an accessible title to the HTML preview frame, and the new test covers it. Two checks are still running: Check and Test Web.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr view 123 --json title,files --jq '.title, .files[].path'"}, "result": "fix(web): HTML preview frame gets an accessible title\napps/web/src/HtmlPreview.tsx\napps/web/src/HtmlPreview.test.tsx"}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t41s\nTypecheck\tpass\t1m2s\nCheck\tpending\t0\nTest Web\tpending\t0"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}, {"role": "user", "text": "Merge it once its checks pass, then settle this thread."}, {"role": "assistant", "text": "T3 Code is watching [#123](https://github.com/acme/app/pull/123). I'll merge it once Check and Test Web pass, then settle this thread.", "toolCalls": [{"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "watching": true, "wasWatching": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default", "t3_thread_organize": {"settlesWhenTurnEnds": true}, "unwatch_pull_request": "default"}, "world": {"branch": "fix/html-preview-title", "commits": [{"sha": "abc1234", "subject": "fix(web): HTML preview frame gets an accessible title"}], "diff": "diff --git a/apps/web/src/HtmlPreview.tsx b/apps/web/src/HtmlPreview.tsx\n--- a/apps/web/src/HtmlPreview.tsx\n+++ b/apps/web/src/HtmlPreview.tsx\n@@ -1,3 +1,3 @@\n export function HtmlPreview({ html }: { html: string }) {\n- return