Skip to content
Open
10 changes: 9 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ const emitStaleXAiPromptCompleteBeforeSecondHang =
const emitOverlappingXAiPromptCompleteOutOfOrder =
process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1";
const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
const failFirstPrompt = process.env.T3_ACP_FAIL_FIRST_PROMPT === "1";
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
const setConfigOptionDelayMs = Number(process.env.T3_ACP_SET_CONFIG_OPTION_DELAY_MS ?? "0");
const permissionOptionIds = {
allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
Expand Down Expand Up @@ -398,6 +400,12 @@ const program = Effect.gen(function* () {

yield* agent.handleSetSessionConfigOption((request) =>
Effect.gen(function* () {
// Cursor selects its model through set_config_option, so this widens the
// sendTurn preparation window: tests can land a concurrent sendTurn
// between the in-flight counter increment and the turn.started emission.
if (Number.isFinite(setConfigOptionDelayMs) && setConfigOptionDelayMs > 0) {
yield* Effect.sleep(`${setConfigOptionDelayMs} millis`);
}
if (exitOnSetConfigOption) {
return yield* Effect.sync(() => {
process.exit(7);
Expand Down Expand Up @@ -461,7 +469,7 @@ const program = Effect.gen(function* () {
yield* Effect.sleep(`${promptDelayMs} millis`);
}

if (failPrompt) {
if (failPrompt || (failFirstPrompt && promptCount === 1)) {
return yield* AcpError.AcpRequestError.internalError("Mock prompt failure");
}

Expand Down
94 changes: 93 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import * as TestClock from "effect/testing/TestClock";
import { attachmentRelativePath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
import {
ProviderAdapterProcessError,
ProviderAdapterRequestError,
ProviderAdapterValidationError,
} from "../Errors.ts";
import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
Expand Down Expand Up @@ -754,6 +758,94 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("does not wedge the turn when an attachment has an unsupported mime type", () => {
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));
const harness = makeHarness({
cwd: "/tmp/project-claude-unsupported-attachment",
baseDir,
});
return Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() =>
NodeFS.rmSync(baseDir, {
recursive: true,
force: true,
}),
),
);

const adapter = yield* ClaudeAdapter;
const { attachmentsDir } = yield* ServerConfig;

const attachment = {
type: "image" as const,
id: "thread-claude-attachment-87654321-4321-4321-4321-cba987654321",
name: "photo.heic",
mimeType: "image/heic",
sizeBytes: 4,
};
const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment));
NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

// Collect exactly the 3 session-startup events plus the single
// turn.started event expected from the *second* (valid) sendTurn
// below. If the unsupported-attachment turn also emitted
// turn.started, this fiber would collect that extra event instead
// and the later assertions on event identity/count would fail.
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 4).pipe(
Stream.runCollect,
Effect.forkChild,
);

const failure = yield* adapter
.sendTurn({
threadId: session.threadId,
input: "What's in this image?",
attachments: [attachment],
})
.pipe(Effect.flip);

assert.instanceOf(failure, ProviderAdapterRequestError);
assert.match(failure.detail, /Unsupported Claude image attachment type 'image\/heic'/u);

// The failed validation must leave the session exactly as it was
// before sendTurn was called: still "ready", no active turn.
const sessionsAfterFailure = yield* adapter.listSessions();
const sessionAfterFailure = sessionsAfterFailure.find(
(candidate) => candidate.threadId === session.threadId,
);
assert.isDefined(sessionAfterFailure);
assert.equal(sessionAfterFailure?.status, "ready");
assert.isUndefined(sessionAfterFailure?.activeTurnId);

// A subsequent, valid turn must still work normally and be the
// only source of a turn.started event.
const turn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "Never mind, just say hi.",
attachments: [],
});

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started");
assert.equal(turnStartedEvents.length, 1);
const turnStarted = turnStartedEvents[0];
if (turnStarted?.type === "turn.started") {
assert.equal(String(turnStarted.turnId), String(turn.turnId));
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
19 changes: 13 additions & 6 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3693,6 +3693,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4);

// Validate/construct the user message BEFORE any turn-state mutation or
// event emission below. buildUserMessageEffect can fail (e.g. an
// unsupported attachment mime type) and must not leave the session
// marked "running" with a turn.started already emitted — that combo
// races the async recovery path and can wedge the turn forever. See
// ProviderCommandReactor's recoverTurnStartFailure.
const message = yield* buildUserMessageEffect(input, {
fileSystem,
attachmentsDir: serverConfig.attachmentsDir,
boundInstanceId,
});

if (steeringTurnState === null) {
const turnState: ClaudeTurnState = {
turnId,
Expand Down Expand Up @@ -3726,12 +3739,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
});
}

const message = yield* buildUserMessageEffect(input, {
fileSystem,
attachmentsDir: serverConfig.attachmentsDir,
boundInstanceId,
});

yield* Queue.offer(context.promptQueue, {
type: "message",
message,
Expand Down
Loading
Loading