Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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
96 changes: 96 additions & 0 deletions scripts/fixtures/issue-17531/seed-native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Create an offline native presentation fixture in an isolated checkout only.
Usage: python seed-native.py /absolute/isolated/checkout
Does not change the renderer, use a server, or seed a live database.
"""
import json, subprocess, sys
from pathlib import Path
root=Path(sys.argv[1]).resolve()
if root.name != 'issue-17531-native-evidence': raise SystemExit('expected dedicated evidence checkout')
sha=subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()
if sha != '26c3625f484eadd4d5b6ca5a3dd1f8f8bd5533b4': raise SystemExit('unexpected source SHA')
mobile=root/'apps/mobile'
fixture=r'''import { useEffect } from "react";
import { View, StatusBar, ScrollView } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { RegistryContext } from "@effect/atom-react";
import * as DateTime from "effect/DateTime";
import { EnvironmentId, ThreadId, TurnItemId, RunId, type OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
import { AppearancePreferencesProvider, useAppearancePreferences } from "./src/features/settings/appearance/AppearancePreferencesProvider";
import { appAtomRegistry } from "./src/state/atom-registry";
import { ThreadWorkLog } from "./src/features/threads/thread-work-log";
import { buildThreadFeed } from "./src/lib/threadActivity";
import { deriveThreadWorkLogSizing } from "./src/lib/layout";
import { AppText as Text } from "./src/components/ui/AppText";
import "./global.css";

const failure = {
class: "provider_error" as const,
code: "claude_mod_ui_unsupported",
message: 'The Claude Code mod "blast-radius" 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.',
retryable: null,
};
const threadId = ThreadId.make("issue17531-static-fixture");
const at = DateTime.makeUnsafe("2026-10-24T09:41:00.000Z");
const projected: OrchestrationV2ProjectedTurnItem = {
position: 0, visibility: "local", sourceThreadId: threadId,
sourceItemId: TurnItemId.make("issue17531-failure"),
item: {
id: TurnItemId.make("issue17531-failure"), threadId,
runId: RunId.make("issue17531-fixture-run"), nodeId: null,
providerThreadId: null, providerTurnId: null, nativeItemRef: null,
parentItemId: null, ordinal: 0, status: "failed", title: "Provider error",
startedAt: at, completedAt: at, updatedAt: at,
type: "error", failure,
},
};
const activities = buildThreadFeed([projected]).flatMap(entry => entry.type === "activity-group" ? entry.activities : []);
const Stack = createNativeStackNavigator();
function FailureFixture() {
const appearance = useAppearancePreferences();
useEffect(() => { appearance.setThemeMode("dark"); }, []);
return <SafeAreaView className="flex-1 bg-background" edges={["bottom"]}>
<StatusBar barStyle="light-content" />
<ScrollView contentContainerStyle={{padding:16, gap:24}}>
<View className="gap-2">
<Text className="font-t3-medium text-sm">STATIC FIXTURE · Issue 17531</Text>
<Text className="text-xs text-foreground-muted">Current native failure renderer · mobile 26c3625f48</Text>
<Text className="text-xs text-foreground-muted">Presentation only; no provider turn was executed.</Text>
</View>
<ThreadWorkLog activities={activities} anchorKey="issue17531" environmentId={EnvironmentId.make("offline-fixture")}
copiedRowId={null} expandedRows={{}} rowSizing={deriveThreadWorkLogSizing({baseFontSize:14,fontScale:1})}
scrollPositions={new Map()} iconSubtleColor="#999" edgeFadeColor="#111111" themeAppearance="dark"
onCopyRow={() => {}} onToggleRow={() => {}} renderImage={() => null}
renderReasoning={text => <Text>{text}</Text>} onPressPreview={() => {}} />
<Text className="text-xs text-foreground-muted">Fixture failure.code: {failure.code}</Text>
</ScrollView>
</SafeAreaView>;
}
export default function NativeEvidenceFixture() {
return <RegistryContext.Provider value={appAtomRegistry}><AppearancePreferencesProvider>
<GestureHandlerRootView style={{flex:1}}><SafeAreaProvider>
<NavigationContainer><Stack.Navigator screenOptions={{headerStyle:{backgroundColor:"#111111"},headerTintColor:"#ffffff",contentStyle:{backgroundColor:"#111111"}}}>
<Stack.Screen name="Issue17531 fixture" component={FailureFixture} />
</Stack.Navigator></NavigationContainer>
</SafeAreaProvider></GestureHandlerRootView>
</AppearancePreferencesProvider></RegistryContext.Provider>;
}
'''
(mobile/'issue17531-fixture.tsx').write_text(fixture)
entry=mobile/'index.ts'
source=subprocess.check_output(['git','show','HEAD:apps/mobile/index.ts'],cwd=root,text=True)
entry.write_text(source.replace('import App from "./src/App";', 'import App from "./issue17531-fixture";'))
config=json.loads((mobile/'eas.json').read_text())
config['build']['issue17531-evidence']={
'extends':'preview', 'developmentClient':False, 'channel':'issue17531-evidence',
'autoIncrement':False, 'env':{'APP_VARIANT':'preview','EXPO_PUBLIC_SHOWCASE':'1','NODE_OPTIONS':'--max-old-space-size=4096'},
'ios':{'simulator':True,'image':'macos-tahoe-26.6-xcode-27.1'}
}
(mobile/'eas.json').write_text(json.dumps(config,indent=2)+'\n')
ignore=mobile/'.gitignore'
text=ignore.read_text() if ignore.exists() else ''
if '.env.eas-simulator' not in text: ignore.write_text(text+'\n.env.eas-simulator\n')
print('offline fixture injected; renderer unchanged; no server/data writes')
27 changes: 27 additions & 0 deletions scripts/fixtures/issue-17531/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Create an isolated Bash/mod reproduction; configure provider credentials separately."""

import argparse
import os
from pathlib import Path
import subprocess

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="new disposable directory")
args = parser.parse_args()
root = args.directory.resolve()
root.mkdir(parents=True, exist_ok=False)
project = root / "project"
(project / "build").mkdir(parents=True)
(project / "build" / "sample.txt").write_text("fixture\n")
subprocess.run(["git", "init", str(project)], check=True)
config = root / "claude"
config.mkdir(mode=0o700)
source = root / "playground"
subprocess.run(["git", "clone", "--no-checkout", "https://github.com/anthropics/claude-code-playground.git", str(source)], check=True)
subprocess.run(["git", "-C", str(source), "checkout", "569c5283d9a0a7ee7938df85bb32e4f48cbb8c86"], check=True)
environment = {**os.environ, "CLAUDE_CONFIG_DIR": str(config)}
subprocess.run(["claude", "plugin", "marketplace", "add", str(source / "claude-code" / "mods")], env=environment, check=True)
subprocess.run(["claude", "plugin", "install", "blast-radius@claude-code-playground-mods", "--scope", "user"], env=environment, check=True)
print(f"Project: {project}\nCLAUDE_CONFIG_DIR: {config}")
print("In T3, select this Claude config and ask: Use Bash to run exactly rm -r build. Do not add -f.")
Loading