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
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () {
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
threadSnoozeUntilAttention: true,
environmentThemes: true,
usageLimitSources: true,
usagePriceOverrides: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as PullRequestWatchReactor from "../orchestration-v2/PullRequestWatchReactor.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
Expand Down Expand Up @@ -72,6 +73,7 @@ const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe(
Layer.mergeAll(
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
NodeServices.layer,
),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpProviderSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface McpProviderSessionConfig {
readonly browserToolsAvailable: boolean;
/** Capabilities the credential grants ("preview", "device"). */
readonly capabilities?: ReadonlySet<string>;
/**
* The user's snoozePullRequestsAwaitingReview setting when the thread attached, which adapters
* pass to the runtime instructions.
*/
readonly snoozePullRequestsAwaitingReview?: boolean;
/**
* Set when the session may drive devices. Adapters spread this into the
* provider subprocess environment so the `agent-device` CLI is on PATH and
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as Stream from "effect/Stream";
import type { Tool } from "effect/ai";

import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import {
type PullRequestTestThread,
v2PullRequestThread,
Expand Down Expand Up @@ -135,6 +136,8 @@ interface HarnessOptions {
readonly project?: OrchestrationProjectShell | null;
/** A rejection the orchestrator reports as the dispatch error's cause. */
readonly reject?: (command: OrchestrationCommand) => string | null;
/** The pull request a snooze cannot read. */
readonly unreadable?: number;
}

const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
Expand Down Expand Up @@ -164,6 +167,18 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
Effect.succeed(id === THREAD_ID && thread ? v2PullRequestThread(thread) : null),
dispatch,
}),
Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({
snoozeUntilAttention: (command) =>
options.unreadable === undefined
? dispatch({ ...command, links: [], watches: [] })
: Effect.fail(
new PullRequestWatchReactor.PullRequestAttentionReadError({
threadId: command.threadId,
number: options.unreadable,
cause: "host unavailable",
}),
),
}),
Layer.succeed(Crypto.Crypto, testCrypto),
McpToolAccessTestkit.liveThreadsLayer,
);
Expand Down Expand Up @@ -302,6 +317,35 @@ describe("pull request toolkit handlers", () => {
}),
);

it.effect("snoozes its own thread until a pull request needs attention", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({ thread: makeThread([makeLink(4)]) });
yield* harness.call("snooze_until_pull_request_needs_attention", {});
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.snooze-until-attention", threadId: THREAD_ID },
]);

// The orchestrator's reason for refusing reaches the agent.
const refused = yield* makeHarness({
reject: () => `Thread ${THREAD_ID} has a queued run and cannot be snoozed.`,
});
const error = yield* refused
.call("snooze_until_pull_request_needs_attention", {})
.pipe(Effect.flip);
expect(error).toMatchObject({ _tag: "PullRequestSnoozeFailedError" });
expect(error.message).toBe(`Thread ${THREAD_ID} has a queued run and cannot be snoozed.`);

// So does a pull request it could not read.
const unreadable = yield* makeHarness({ thread: makeThread([makeLink(4)]), unreadable: 4 });
const unread = yield* unreadable
.call("snooze_until_pull_request_needs_attention", {})
.pipe(Effect.flip);
expect(unread.message).toBe(
"Could not read pull request #4, so the thread was not snoozed. Try again.",
);
}),
);

it.effect("refuses a watch from a subagent thread, whose parent owns the pull request", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
Expand Down
57 changes: 56 additions & 1 deletion apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";

import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
Expand All @@ -36,6 +37,7 @@ import {
PullRequestUnlinkFailedError,
PullRequestListFailedError,
PullRequestNotOpenError,
PullRequestSnoozeFailedError,
type PullRequestTargetInput,
PullRequestWatchFailedError,
PullRequestWatchFromSubagentError,
Expand All @@ -45,6 +47,21 @@ import {
type ThreadPullRequestEntry,
} from "./tools.ts";

/** Why T3 Code refused a command, which the agent can act on. */
function refusalOf(
cause: Cause.Cause<
Orchestrator.OrchestratorV2Error | PullRequestWatchReactor.PullRequestAttentionReadError
>,
): string | undefined {
const error = Option.getOrUndefined(Cause.findErrorOption(cause));
if (error?._tag === "PullRequestAttentionReadError") return error.message;
return (error?._tag === "OrchestratorDispatchError" ||
error?._tag === "OrchestratorCommandRejectedError") &&
typeof error.cause === "string"
? error.cause
: undefined;
}

interface ResolvedTarget {
readonly host: string;
readonly repository: string;
Expand Down Expand Up @@ -155,6 +172,7 @@ export function listThreadPullRequests(

const make = Effect.gen(function* () {
const engine = yield* Orchestrator.OrchestratorV2;
const pullRequestWatch = yield* PullRequestWatchReactor.PullRequestWatchReactor;

const projects = yield* ProjectService.ProjectService;
const crypto = yield* Crypto.Crypto;
Expand All @@ -170,7 +188,8 @@ const make = Effect.gen(function* () {
| typeof PullRequestLinkFailedError
| typeof PullRequestUnlinkFailedError
| typeof PullRequestListFailedError
| typeof PullRequestWatchFailedError,
| typeof PullRequestWatchFailedError
| typeof PullRequestSnoozeFailedError,
requested: ThreadId | undefined,
) {
const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
Expand Down Expand Up @@ -342,6 +361,42 @@ const make = Effect.gen(function* () {
),
watch_pull_request: writesThread((input) => setWatching(input, true)),
unwatch_pull_request: writesThread((input) => setWatching(input, false)),
snooze_until_pull_request_needs_attention: writesThread((input) =>
Effect.gen(function* () {
const thread = yield* requireThread(PullRequestSnoozeFailedError, input.threadId);
yield* pullRequestWatch
.snoozeUntilAttention({
type: "thread.snooze-until-attention",
commandId: yield* commandId("mcp-pr-snooze", thread.id),
threadId: thread.id,
createdBy: "agent",
})
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause as Cause.Cause<never>)
: Effect.fail(
new PullRequestSnoozeFailedError({ reason: refusalOf(cause), cause }),
),
),
);
const after = yield* requireThread(PullRequestSnoozeFailedError, thread.id);
return {
watching: visibleThreadPullRequests(after.pullRequests ?? []).flatMap((link) =>
link.watch === undefined
? []
: [
{
host: normalizeThreadPullRequestKey(link).host,
repository: link.repository,
number: link.number,
url: link.url,
},
],
),
};
}),
),
} satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>;
});

Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ export class PullRequestWatchFromSubagentError extends Schema.TaggedError<PullRe
}
}

export class PullRequestSnoozeFailedError extends Schema.TaggedError<PullRequestSnoozeFailedError>()(
"PullRequestSnoozeFailedError",
{
/** Why T3 Code refused, when it did. */
reason: Schema.optional(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return this.reason ?? "Could not snooze the thread.";
}
}

export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
"PullRequestListFailedError",
{ cause: Schema.Defect() },
Expand All @@ -176,6 +189,7 @@ export const PullRequestToolError = Schema.Union([
PullRequestWatchFailedError,
PullRequestNotOpenError,
PullRequestWatchFromSubagentError,
PullRequestSnoozeFailedError,
]);
export type PullRequestToolError = typeof PullRequestToolError.Type;

Expand Down Expand Up @@ -215,6 +229,14 @@ export const WatchPullRequestResult = Schema.Struct({
});
export type WatchPullRequestResult = typeof WatchPullRequestResult.Type;

export const SnoozeUntilPullRequestNeedsAttentionResult = Schema.Struct({
watching: Schema.Array(Schema.Struct(PullRequestIdentity)).annotate({
description: "The pull requests T3 Code now watches for this thread.",
}),
});
export type SnoozeUntilPullRequestNeedsAttentionResult =
typeof SnoozeUntilPullRequestNeedsAttentionResult.Type;

export const ThreadPullRequestEntry = Schema.Struct({
...PullRequestIdentity,
source: ThreadPullRequestLinkSource,
Expand Down Expand Up @@ -319,10 +341,32 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const SnoozeUntilPullRequestNeedsAttentionTool = Tool.make(
"snooze_until_pull_request_needs_attention",
{
description:
"Snooze a thread, by default this one, until one of its pull requests needs attention. T3 Code watches every open pull request linked to it, starting watches where needed, and snoozes the thread with no wake time. A watch wakes you as usual when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict, and the wake brings the thread back. Once no watched pull request is left open, because they merged or closed, the thread settles. It fails after the user stops the thread's run, while the thread has a queued message or a pending approval or question, when the thread has no open linked pull request, and in a subagent.",
parameters: Schema.Struct({
threadId: Schema.optional(
ThreadId.annotate({ description: "Thread to snooze. Omit for this thread." }),
),
}),
success: SnoozeUntilPullRequestNeedsAttentionResult,
failure: PullRequestToolError,
dependencies,
},
)
.annotate(Tool.Title, "Snooze until a pull request needs attention")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

export const PullRequestsToolkit = Toolkit.make(
LinkPullRequestTool,
UnlinkPullRequestTool,
ListThreadPullRequestsTool,
WatchPullRequestTool,
UnwatchPullRequestTool,
SnoozeUntilPullRequestNeedsAttentionTool,
);
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts";
import * as DeviceService from "../../../device/DeviceService.ts";
import * as ServerConfig from "../../../config.ts";
Expand Down Expand Up @@ -29,6 +30,7 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";

const layerStubServices = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(DeviceService.DeviceService)({}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6790,6 +6790,9 @@ export function makeAcpAdapterV2(
text: buildRuntimeInstructions({
harness: flavor.runtimeHarness ?? driver,
model: turnInput.modelSelection.model,
snoozePullRequestsAwaitingReview: McpProviderSession.readMcpProviderSession(
turnInput.threadId,
)?.snoozePullRequestsAwaitingReview,
}),
});
return { prompt, instructionState: text === messageText ? undefined : instructionState };
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,8 @@ export function makeClaudeQueryOptions(input: {
readonly sdkSettings?: string | ClaudeSdkSettings;
readonly environment?: NodeJS.ProcessEnv;
readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
/** The user's snoozePullRequestsAwaitingReview setting, for the runtime instructions. */
readonly snoozePullRequestsAwaitingReview?: boolean;
readonly tools?: ClaudeAgentSdkQueryTools;
readonly allowedTools?: ReadonlyArray<string>;
readonly disallowedTools?: ReadonlyArray<string>;
Expand Down Expand Up @@ -909,8 +911,10 @@ export function makeClaudeQueryOptions(input: {
type: "preset" as const,
preset: "claude_code" as const,
append:
buildRuntimeInstructions({ harness: "Claude Code" }) +
(input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
buildRuntimeInstructions({
harness: "Claude Code",
snoozePullRequestsAwaitingReview: input.snoozePullRequestsAwaitingReview,
}) + (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
},
...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
};
Expand Down Expand Up @@ -7131,6 +7135,9 @@ export function makeClaudeAdapterV2(
environment: adapterOptions.environment,
tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
...mcpOverrides,
snoozePullRequestsAwaitingReview:
McpProviderSession.readMcpProviderSession(turnInput.threadId)
?.snoozePullRequestsAwaitingReview === true,
permissionMode: queryPolicy.permissionMode,
...(queryPolicy.allowDangerouslySkipPermissions === undefined
? {}
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ export function buildCodexTurnStartParams(input: {
readonly hasT3Mcp?: boolean;
readonly browserToolsAvailable?: boolean;
readonly deviceToolsAvailable?: boolean;
readonly snoozePullRequestsAwaitingReview?: boolean;
/** ChatGPT token sharing does not accept service tiers. */
readonly omitServiceTier?: boolean;
}) {
Expand Down Expand Up @@ -735,7 +736,11 @@ export function buildCodexTurnStartParams(input: {
const additionalContext =
input.hasT3Mcp === true
? buildCodexAdditionalContext(
{ model: input.modelSelection.model, reasoningEffort: effort ?? "medium" },
{
model: input.modelSelection.model,
reasoningEffort: effort ?? "medium",
snoozePullRequestsAwaitingReview: input.snoozePullRequestsAwaitingReview,
},
{
browser: input.browserToolsAvailable ?? true,
device: input.deviceToolsAvailable ?? false,
Expand Down Expand Up @@ -5873,6 +5878,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
hasT3Mcp: mcpSession !== undefined,
browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
snoozePullRequestsAwaitingReview:
mcpSession?.snoozePullRequestsAwaitingReview === true,
omitServiceTier: adapterOptions.resolveRuntime !== undefined,
});
yield* Ref.update(pendingRootTurns, (current) => {
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2175,7 +2175,14 @@ export function makeCursorAdapterV2(
detail: "Cursor turn requires non-empty text or attachments.",
});
}
const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`;
const runtimeInstructions = buildRuntimeInstructions({
harness: "Cursor",
model: turnInput.modelSelection.model,
snoozePullRequestsAwaitingReview: McpProviderSession.readMcpProviderSession(
turnInput.threadId,
)?.snoozePullRequestsAwaitingReview,
});
const text = `${userText}\n\n${runtimeInstructions}`;
return images.length === 0
? text
: ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3285,7 +3285,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
if (added) state.mcp = wanted;
}
const instructions = [
buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),
buildRuntimeInstructions({
harness: "OpenCode",
model: turnInput.modelSelection.model,
snoozePullRequestsAwaitingReview: mcpSession?.snoozePullRequestsAwaitingReview,
}),
t3OrchestrationSystemPrompt(state.mcp !== undefined),
]
.filter((part) => part !== undefined && part.length > 0)
Expand Down
Loading
Loading