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
91 changes: 91 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
NodeId,
OrchestratorMcpThreadReadResult,
ProviderDriverKind,
ProjectId,
ProviderInstanceId,
RunId,
RuntimeRequestId,
Expand All @@ -35,6 +36,10 @@ import {
} from "../../orchestration-v2/Orchestrator.ts";

import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts";
import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts";
import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts";
import * as SqlitePersistence from "../../persistence/Sqlite.ts";
import * as ServerConfig from "../../config.ts";
import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts";
Expand Down Expand Up @@ -579,6 +584,92 @@ it.effect("a read-only client reads threads and is refused every write before it
);
const dispatched: Array<string> = [];

const layerPersistedThreads = (() => {
const layerDatabase = SqlitePersistence.layerMemory;
const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
{ name: "mcp-core-auto-settle" },
ProviderAdapterRegistry.layerFromAdapters([]),
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
return Layer.merge(
layerOrchestrator,
ThreadManagement.layer.pipe(
Layer.provide(layerOrchestrator),
Layer.provide(ServerSettings.layerTest()),
Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
),
);
})();

it.effect("an agent turns auto-settle off and back on for only the thread it targets", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const orchestrator = yield* Orchestrator.OrchestratorV2;
const threads = yield* ThreadManagement.ThreadManagementService;
const targetId = ThreadId.make("auto-settle-target");
const siblingId = ThreadId.make("auto-settle-sibling");
for (const id of [targetId, siblingId]) {
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make(`create-${id}`),
threadId: id,
projectId: ProjectId.make("project:mcp-core"),
title: id,
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdBy: "user",
creationSource: "web",
});
}
yield* orchestrator.dispatch({
type: "thread.unsettle",
commandId: CommandId.make("manual-auto-settle-override"),
threadId: targetId,
reason: "user",
});
const manualOverride = (yield* threads.getThreadShell(targetId))?.settledOverride;
expect(manualOverride).toBe("active");
const organize = (
action: string,
invocation: McpInvocationContext.McpInvocationScope = clientScope("full-access"),
) =>
server
.callTool({ name: "t3_thread_organize", arguments: { action, threadId: targetId } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);
const autoSettleDisabled = (id: ThreadId) =>
threads.getThreadShell(id).pipe(Effect.map((shell) => shell?.autoSettleDisabledAt != null));

const disabled = yield* organize("disable_auto_settle");
expect(disabled.isError).toBe(false);
expect(yield* autoSettleDisabled(targetId)).toBe(true);
expect(yield* autoSettleDisabled(siblingId)).toBe(false);
expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride);

const readOnly = yield* organize("enable_auto_settle", clientScope("read-only"));
expect(declaredFailure(readOnly)).toMatchObject({ code: "capability_denied" });
expect(yield* autoSettleDisabled(targetId)).toBe(true);

const enabled = yield* organize("enable_auto_settle");
expect(enabled.isError).toBe(false);
expect(yield* autoSettleDisabled(targetId)).toBe(false);
expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride);
}).pipe(
Effect.provide(
layerThreadToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(NodeCrypto.layer),
Layer.provideMerge(layerPersistedThreads),
),
),
),
);

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 @@ -351,6 +351,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,61 @@ 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);

yield* orchestrator.dispatch({
type: "thread.auto-settle.set",
commandId: CommandId.make("enable-auto-settle-race"),
threadId,
enabled: true,
});
yield* orchestrator.dispatch({
type: "thread.auto-settle",
commandId: CommandId.make("enabled-auto-settle-race"),
threadId,
snapshotAt,
});
assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled");
}).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 @@ -12402,6 +12402,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": 5135,
"codexAdditionalContextBytes": 6865,
"mcpToolListJsonBytes": 45907
"mcpToolListJsonBytes": 46063
}
3 changes: 2 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,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
Loading
Loading