Skip to content
Merged
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
46 changes: 42 additions & 4 deletions apps/server/src/actionResume/ActionResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)({
Expand All @@ -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");
Expand Down
31 changes: 18 additions & 13 deletions apps/server/src/actionResume/ActionResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -269,14 +274,14 @@ const make = Effect.gen(function* () {
const decodeState = Schema.decodeUnknownEffect(ActionResumeState);
const outputCaptureByRunId = new Map<string, ActionOutputCapture>();

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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/actionResume/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import {
ApprovalRequestId,
ClaudeSettings,
EnvironmentId,
ProviderDriverKind,
ProviderItemId,
ProviderRuntimeEvent,
Expand All @@ -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";
Expand Down Expand Up @@ -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* () {
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 <Project Action>".
alwaysLoad: true,
},
},
}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/projectScriptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,11 @@ export function ProjectScriptEditorDialog({
</label>
<label className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
<span className="space-y-0.5">
<span className="block">Allow agents to run and resume</span>
<span className="block">Allow Codex and Claude to run and resume</span>
<span className="block text-xs text-muted-foreground">
Lets a Codex agent launch this command and receive one automated follow-up when
it finishes. The terminal transcript stays available as the output artifact.
Lets Codex or Claude launch this command and receive one automated follow-up
when it finishes. The terminal transcript stays available as the output
artifact.
Comment thread
lastobelus marked this conversation as resolved.
</span>
</span>
<Switch
Expand Down