diff --git a/apps/server/src/actionResume/ActionResume.test.ts b/apps/server/src/actionResume/ActionResume.test.ts index 286366af68bb..df82c8437692 100644 --- a/apps/server/src/actionResume/ActionResume.test.ts +++ b/apps/server/src/actionResume/ActionResume.test.ts @@ -33,6 +33,8 @@ import * as ActionResume from "./ActionResume.ts"; const threadId = ThreadId.make("thread-action-resume"); const projectId = ProjectId.make("project-action-resume"); const providerInstanceId = ProviderInstanceId.make("codex"); +const claudeProviderInstanceId = ProviderInstanceId.make("claudeAgent"); +const openCodeProviderInstanceId = ProviderInstanceId.make("opencode"); const now = "2026-08-17T00:00:00.000Z"; const thread = { @@ -170,6 +172,14 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up instanceId: providerInstanceId, driver: ProviderDriverKind.make("codex"), } as never, + { + instanceId: claudeProviderInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + } as never, + { + instanceId: openCodeProviderInstanceId, + driver: ProviderDriverKind.make("opencode"), + } as never, ]), ThreadActionResume.layer, Layer.mock(UpdateDrainAdmission)({ @@ -194,19 +204,47 @@ it.effect("runs one opted-in Action and delivers exactly one automated follow-up ], ); + const claudeListed = yield* service.listProjectActions({ + threadId, + providerInstanceId: claudeProviderInstanceId, + }); + assert.isTrue(claudeListed.find(({ id }) => id === "qa")?.resumeEligible); + + const unsupportedListed = yield* service.listProjectActions({ + threadId, + providerInstanceId: openCodeProviderInstanceId, + }); + assert.isFalse(unsupportedListed.find(({ id }) => id === "qa")?.resumeEligible); + const unsupportedRun = yield* service + .runProjectActionAndResume({ threadId, providerInstanceId: openCodeProviderInstanceId }, "qa") + .pipe(Effect.flip); + assert.equal(unsupportedRun.reason, "unsupported_provider"); + + const claudeRunning = yield* service.runProjectActionAndResume( + { threadId, providerInstanceId: claudeProviderInstanceId }, + "qa", + ); + assert.equal(claudeRunning.outcome, "running"); + yield* terminalListener!({ + type: "closed", + threadId, + terminalId: claudeRunning.terminalId, + deleteHistory: true, + }); + const running = yield* service.runProjectActionAndResume( { threadId, providerInstanceId }, "qa", ); assert.equal(running.outcome, "running"); - assert.equal(opened.length, 1); - assert.equal(written.length, 1); + assert.equal(opened.length, 2); + assert.equal(written.length, 2); assert.isBelow( timeline.indexOf("terminal:open"), timeline.indexOf("dispatch:thread.activity.append"), ); - assert.match(written[0]?.data ?? "", /vp test run/); - assert.match(written[0]?.data ?? "", /exit \$__t3_action_status/); + assert.match(written.at(-1)?.data ?? "", /vp test run/); + assert.match(written.at(-1)?.data ?? "", /exit \$__t3_action_status/); assert.isDefined(terminalListener); const startMarker = ActionResume.actionOutputMarker(running.runId, "start"); diff --git a/apps/server/src/actionResume/ActionResume.ts b/apps/server/src/actionResume/ActionResume.ts index 3688753d0b35..38b29e7fac2e 100644 --- a/apps/server/src/actionResume/ActionResume.ts +++ b/apps/server/src/actionResume/ActionResume.ts @@ -41,6 +41,11 @@ import { UpdateDrainAdmission } from "../updateDrain/UpdateDrainAdmission.ts"; export const ACTION_RESUME_ACTIVITY_KIND = "action.resume.lifecycle"; +const ACTION_RESUME_PROVIDER_DRIVERS = new Set([ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), +]); + export interface ListedProjectAction { readonly id: string; readonly name: string; @@ -269,14 +274,14 @@ const make = Effect.gen(function* () { const decodeState = Schema.decodeUnknownEffect(ActionResumeState); const outputCaptureByRunId = new Map(); - const providerIsCodex = Effect.fn("ActionResume.providerIsCodex")(function* ( - providerInstanceId: ProviderInstanceId, - ) { - const provider = (yield* providers.getProviders).find( - (entry) => entry.instanceId === providerInstanceId, - ); - return provider?.driver === ProviderDriverKind.make("codex"); - }); + const providerSupportsActionResume = Effect.fn("ActionResume.providerSupportsActionResume")( + function* (providerInstanceId: ProviderInstanceId) { + const provider = (yield* providers.getProviders).find( + (entry) => entry.instanceId === providerInstanceId, + ); + return provider !== undefined && ACTION_RESUME_PROVIDER_DRIVERS.has(provider.driver); + }, + ); const persistState = Effect.fn("ActionResume.persistState")(function* (state: ActionResumeState) { const previous = registry.getLatest(state.threadId); @@ -498,12 +503,12 @@ const make = Effect.gen(function* () { const listProjectActionsImpl = Effect.fn("ActionResume.listProjectActions")(function* ( invocation: ActionResumeInvocation, ) { - const codex = yield* providerIsCodex(invocation.providerInstanceId); + const providerSupported = yield* providerSupportsActionResume(invocation.providerInstanceId); const { project } = yield* resolveProjectContext(invocation.threadId); const launchBlocked = actionBlocksNewLaunch(registry.getLatest(invocation.threadId)); return project.scripts.map((script) => { - const disabledReason = !codex - ? "Resume-capable Actions are available to Codex providers in this first slice." + const disabledReason = !providerSupported + ? "Resume-capable Actions are currently available to Codex and Claude providers." : script.allowAgentResume !== true ? "This Action has not been opted in for agent-triggered resume." : launchBlocked @@ -593,10 +598,10 @@ const make = Effect.gen(function* () { const runProjectActionAndResumeImpl = Effect.fn("ActionResume.runProjectActionAndResume")( function* (invocation: ActionResumeInvocation, actionId: string) { - if (!(yield* providerIsCodex(invocation.providerInstanceId))) { + if (!(yield* providerSupportsActionResume(invocation.providerInstanceId))) { return yield* new ActionResumeError({ reason: "unsupported_provider", - message: "Resume-capable Actions are available to Codex providers in this first slice.", + message: "Resume-capable Actions are currently available to Codex and Claude providers.", }); } const { project } = yield* resolveProjectContext(invocation.threadId); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 3da417af3a7d..c517a6a85106 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -78,6 +78,11 @@ it.effect("rejects MCP action launch while update drain admission is closed", () const result = yield* Effect.gen(function* () { const server = yield* McpServer.McpServer; + const listTool = server.tools.find(({ tool }) => tool.name === "list_project_actions"); + expect(listTool?.tool.inputSchema).toEqual({ + type: "object", + additionalProperties: false, + }); return yield* server .callTool({ name: "run_project_action_and_resume", arguments: { actionId: "qa" } }) .pipe( diff --git a/apps/server/src/mcp/toolkits/actionResume/tools.ts b/apps/server/src/mcp/toolkits/actionResume/tools.ts index 6672c38d40e1..8c7cfc55e014 100644 --- a/apps/server/src/mcp/toolkits/actionResume/tools.ts +++ b/apps/server/src/mcp/toolkits/actionResume/tools.ts @@ -21,7 +21,7 @@ const ListedProjectAction = Schema.Struct({ export const ListProjectActionsTool = Tool.make("list_project_actions", { description: "List every saved Project Action for this thread's project, including its stable id, name, whether it is opted in for agent-triggered one-shot resume, and a safe reason when it is disabled. Call this before run_project_action_and_resume; never guess an Action id.", - parameters: Schema.Struct({}), + parameters: Schema.Record(Schema.String, Schema.Never), success: Schema.Struct({ actions: Schema.Array(ListedProjectAction) }), failure: ActionResumeToolError, dependencies, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6ec0a1ab6288..24a7d19daa06 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -14,6 +14,7 @@ import type { import { ApprovalRequestId, ClaudeSettings, + EnvironmentId, ProviderDriverKind, ProviderItemId, ProviderRuntimeEvent, @@ -34,6 +35,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; @@ -355,6 +357,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("always loads the authenticated T3 MCP server", () => { + const harness = makeHarness(); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-claude-mcp"), + threadId: THREAD_ID, + providerSessionId: "provider-session-claude-mcp", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + endpoint: "http://127.0.0.1:9876/mcp", + authorizationHeader: "Bearer test-token", + }); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + assert.deepEqual(harness.getLastCreateQueryInput()?.options.mcpServers?.["t3-code"], { + type: "http", + url: "http://127.0.0.1:9876/mcp", + headers: { Authorization: "Bearer test-token" }, + alwaysLoad: true, + }); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: + "When the user asks to run a saved Project Action by name, call mcp__t3-code__list_project_actions. If exactly one Action matches that name, call mcp__t3-code__run_project_action_and_resume with its id; ask the user to clarify if multiple Actions match. End your turn immediately after launch so the automated follow-up can arrive; do not search for or reproduce the Action command.", + }); + }).pipe( + Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID))), + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 02d73e372d2b..959e4d4b978c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4159,7 +4159,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, - systemPrompt: { type: "preset", preset: "claude_code" }, + systemPrompt: { + type: "preset", + preset: "claude_code", + ...(mcpSession + ? { + append: + "When the user asks to run a saved Project Action by name, call mcp__t3-code__list_project_actions. If exactly one Action matches that name, call mcp__t3-code__run_project_action_and_resume with its id; ask the user to clarify if multiple Actions match. End your turn immediately after launch so the automated follow-up can arrive; do not search for or reproduce the Action command.", + } + : {}), + }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is // normalized to `xhigh` above and paired with `settings.ultracode`. @@ -4189,6 +4198,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( headers: { Authorization: mcpSession.authorizationHeader, }, + // Product-native tools must be available when Claude interprets + // prompts such as "run ". + alwaysLoad: true, }, }, } diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index f4d2fe3075f5..99bc62f84aff 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -361,10 +361,11 @@ export function ProjectScriptEditorDialog({