Skip to content
Merged
166 changes: 166 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7491,6 +7491,172 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

// regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it
it.effect.each([
{ case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true },
{ case: "keeps a turn running beside a passive mod pane", focus: false },
] as const)("$case", ({ focus }) =>
Effect.scoped(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>();
const harness = yield* makeWakeHarnessWithOptions({
close: () => Deferred.succeed(closed, undefined),
});
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"),
text: "Run rm -r build",
attachments: [],
}),
);
// Captured from Claude 2.1.303 with the blast-radius mod holding a Bash
// call: the mod waits for keyboard input in its pane and nothing else
// arrives until it gives up.
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_panes",
panes: [
{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 },
],
shown_id: "blast-radius",
focused_id: null,
focus_requested_id: focus ? "blast-radius" : null,
uuid: "00000000-0000-4000-8000-000000000795",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_invalidate",
event: "ui.render",
uuid: "00000000-0000-4000-8000-000000000796",
session_id: WAKE_NATIVE_SESSION,
}),
);
// Frames are handled in order, so a later notice proves the pane frame
// was handled without the turn ending.
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "informational",
content: "Processing barrier.",
level: "warning",
uuid: "00000000-0000-4000-8000-000000000797",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* harness.until(
() =>
harness.terminalEvents().length > 0 ||
harness.events.some(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "system_notice",
),
);
const [terminal] = harness.terminalEvents();
if (!focus) {
assert.equal(terminal, undefined);
return;
}
assert.equal(terminal?.status, "failed");
if (terminal?.status !== "failed") return;
assert.include(terminal.failure.message, "blast-radius");
// The held tool call is abandoned by closing the query, never answered.
yield* Deferred.await(closed);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

// regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process
it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () =>
Effect.scoped(
Effect.gen(function* () {
const releaseClose = yield* Deferred.make<void>();
const replacementClosedOld = yield* Deferred.make<void>();
let oldCloses = 0;
const harness = yield* makeWakeHarnessWithOptions({
// The held CLI never answers an interrupt; closure must not need one.
interrupt: Effect.never,
close: (sdkMessages) =>
Effect.suspend(() =>
++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void,
).pipe(
Effect.andThen(Deferred.await(releaseClose)),
Effect.andThen(Queue.shutdown(sdkMessages)),
),
freshQueueOnReopen: true,
});
// Added after the harness so it runs first: a failed assertion must not
// leave teardown waiting on the held close.
yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined));
const now = yield* DateTime.now;
const turnInput = makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"),
text: "Run rm -r build",
attachments: [],
});
yield* harness.runtime.startTurn(turnInput);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "ui_panes",
panes: [
{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 },
],
shown_id: "blast-radius",
focused_id: null,
focus_requested_id: "blast-radius",
uuid: "00000000-0000-4000-8000-000000000798",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed");

const followUp = yield* harness.runtime
.startTurn({
...turnInput,
attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"),
providerTurnOrdinal: 2,
})
.pipe(Effect.forkScoped);
// Reusing the closing process would offer the prompt to it instead.
yield* Effect.raceFirst(
Deferred.await(replacementClosedOld),
harness.until(() => harness.offeredMessages.length >= 2),
);
assert.isTrue(yield* Deferred.isDone(replacementClosedOld));
yield* Deferred.succeed(releaseClose, undefined);
yield* Fiber.join(followUp);
assert.equal(harness.processQueues.length, 2);
yield* harness.offerAndWait(
makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

// regression: #16897 a prompt whose uuid the session already holds must not hang or end early
it.effect("keeps a started prompt running until its result", () =>
Effect.scoped(
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3764,6 +3764,20 @@ function claudeTaskString(value: unknown, key: string): string | undefined {
return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined;
}

// A Claude Code mod can hold a tool call until the user acts in its terminal
// pane. T3 cannot provide the requested pane interaction (#17531).
// Passive panes never request focus and keep working.
function claudeFocusRequestedPanePlugin(message: SDKMessage): string | null {
if (message.type !== "system" || claudeTaskField(message, "subtype") !== "ui_panes") return null;
const paneId = claudeTaskString(message, "focus_requested_id");
if (paneId === undefined) return null;
const panes = claudeTaskField(message, "panes");
const pane = Array.isArray(panes)
? panes.find((entry) => claudeTaskField(entry, "id") === paneId)
: undefined;
return claudeTaskString(pane, "plugin") ?? paneId;
}

function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined {
switch (value) {
case "pending":
Expand Down Expand Up @@ -7603,6 +7617,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
return;
}
const focusedPlugin = claudeFocusRequestedPanePlugin(message);
if (focusedPlugin !== null && context === (yield* Ref.get(activeTurn))) {
yield* Effect.logWarning("orchestration-v2.claude-mod-pane-focus-unsupported", {
providerTurnId: context.providerTurnId,
plugin: focusedPlugin,
});
liveQuery.stopping = true;
yield* finalizeActiveTurn({
context,
status: "failed",
completedAt: yield* DateTime.now,
failure: makeProviderFailure({
class: "provider_error",
code: "claude_mod_ui_unsupported",
message: `The Claude Code mod "${focusedPlugin}" is waiting for input in its terminal pane, which T3 Code cannot show. Disable the mod for sessions run from T3 Code, or run the task in the Claude Code terminal.`,
}),
});
// Closing ends the CLI process and abandons the held tool call
// without answering it. Not interrupt: the held CLI may never ack it.
yield* liveQuery.query.close.pipe(Effect.ignore);
return;
}
yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message });

if (message.type === "stream_event" && !message.parent_tool_use_id) {
Expand Down Expand Up @@ -9206,6 +9242,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (
existing !== null &&
existing.nativeThreadId === nativeThreadId &&
!existing.stopping &&
isClaudeProviderContinuationTurn(turnInput)
) {
return existing;
Expand Down Expand Up @@ -9237,6 +9274,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
existing !== null &&
existing.nativeThreadId === nativeThreadId &&
existing.queryPolicyKey === queryPolicyKey &&
!existing.stopping &&
existing.selection.queryIdentity === compiledSelection.queryIdentity
) {
// A denied ExitPlanMode leaves Claude in plan mode. Restore only from plan:
Expand Down
Loading