drive the Claude pane over stream-json with native prompts
Replaces the Claude pane's tmux-TUI + transcript-tail backend with Claude Code's stream-json control protocol (D-77/D-78). A StreamJsonSession owns the `claude` process: its event stream feeds the existing ConversationController, and permission / AskUserQuestion prompts arrive as can_use_tool control_requests. Those surface as a ToolPrompt in the composer zone — the pane swaps the text input for an Allow/Deny card or an option picker while a prompt is open, so interaction stays out of the conversation stream and the prompt buttons don't fight the message-card hover chrome. The decision is written back as a control_response (allow echoes updatedInput; AskUserQuestion answers go in updatedInput.answers). Unsupported control subtypes are answered with an error so a turn never hangs. Session continuity is --resume (existing transcript) vs --session-id (new); /clear and /resume respawn the process. The transcript reader still backs the sidebar/status/team surfaces. T-165, T-166. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
ToolPrompt permissionPrompt() => const ToolPrompt(
|
||||
promptId: 'req-1',
|
||||
toolName: 'Write',
|
||||
displayName: 'Write',
|
||||
description: 'banana.txt',
|
||||
input: {'file_path': '/tmp/banana.txt', 'content': 'banana'},
|
||||
);
|
||||
|
||||
ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt(
|
||||
promptId: 'req-q',
|
||||
toolName: 'AskUserQuestion',
|
||||
displayName: 'AskUserQuestion',
|
||||
input: {
|
||||
'questions': [
|
||||
{
|
||||
'question': 'Do you prefer cats or dogs?',
|
||||
'header': 'Pet',
|
||||
'multiSelect': multi,
|
||||
'options': [
|
||||
{'label': 'Cats', 'description': 'cat person'},
|
||||
{'label': 'Dogs', 'description': 'dog person'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('permission card: Allow returns AllowTool echoing the input', (tester) async {
|
||||
ToolDecision? decision;
|
||||
String? id;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ToolPromptCard(
|
||||
prompt: permissionPrompt(),
|
||||
onResolve: (p, d) {
|
||||
id = p;
|
||||
decision = d;
|
||||
},
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('permission · Write'), findsOneWidget);
|
||||
expect(find.text('Allow'), findsOneWidget);
|
||||
expect(find.text('Deny'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Allow'));
|
||||
await tester.pump();
|
||||
|
||||
expect(id, 'req-1');
|
||||
expect(decision, isA<AllowTool>());
|
||||
expect((decision as AllowTool).updatedInput['content'], 'banana');
|
||||
});
|
||||
|
||||
testWidgets('permission card: Deny returns DenyTool with a message', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Deny'));
|
||||
await tester.pump();
|
||||
|
||||
expect(decision, isA<DenyTool>());
|
||||
expect((decision as DenyTool).message, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('question card: Submit is gated until an option is picked, then returns answers', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
|
||||
|
||||
// Submit before choosing → no-op (disabled).
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
expect(decision, isNull);
|
||||
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
|
||||
expect(decision, isA<AllowTool>());
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Do you prefer cats or dogs?'], 'Dogs');
|
||||
});
|
||||
|
||||
testWidgets('question card: multi-select joins chosen labels comma-separated', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.textContaining('Cats'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.textContaining('Dogs'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
|
||||
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
|
||||
expect(answers['Do you prefer cats or dogs?'], 'Cats, Dogs');
|
||||
});
|
||||
}
|
||||
@@ -81,11 +81,11 @@ void main() {
|
||||
test('resumes an existing session with --resume, not --session-id', () {
|
||||
// --session-id refuses an existing id ("already in use"), so resuming
|
||||
// (transcript on disk) must use --resume.
|
||||
expect(claudeLaunchArgs('abc', resume: true), ['claude', '--resume', 'abc']);
|
||||
expect(claudeLaunchArgs('abc', resume: true), ['--resume', 'abc']);
|
||||
});
|
||||
|
||||
test('creates a new session with --session-id', () {
|
||||
expect(claudeLaunchArgs('abc', resume: false), ['claude', '--session-id', 'abc']);
|
||||
expect(claudeLaunchArgs('abc', resume: false), ['--session-id', 'abc']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ String initEvent() => jsonEncode({
|
||||
'permissionMode': 'default',
|
||||
});
|
||||
|
||||
String canUseTool(String rid, {String tool = 'Write', Map<String, dynamic>? input}) => jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': rid,
|
||||
'request': {
|
||||
'subtype': 'can_use_tool',
|
||||
'tool_name': tool,
|
||||
'display_name': tool,
|
||||
'description': 'banana.txt',
|
||||
'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'},
|
||||
'tool_use_id': 'toolu_1',
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
late _FakeProc proc;
|
||||
late StreamJsonSession session;
|
||||
@@ -129,6 +142,90 @@ void main() {
|
||||
expect(echoed.single.text, 'do the thing');
|
||||
});
|
||||
|
||||
test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async {
|
||||
final emitted = <ToolPrompt?>[];
|
||||
session.pendingPromptStream.listen(emitted.add);
|
||||
proc.emit(canUseTool('req-1'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final p = session.pendingPrompt;
|
||||
expect(p, isNotNull);
|
||||
expect(p!.promptId, 'req-1');
|
||||
expect(p.toolName, 'Write');
|
||||
expect(p.displayName, 'Write');
|
||||
expect(p.description, 'banana.txt');
|
||||
expect(p.toolUseId, 'toolu_1');
|
||||
expect(p.input['content'], 'banana');
|
||||
expect(emitted.last, isNotNull); // surfaced on the stream
|
||||
expect(items, isEmpty); // prompts are not conversation items
|
||||
expect(proc.writes, isEmpty); // no response until resolved
|
||||
});
|
||||
|
||||
test('resolvePrompt(allow) writes success+updatedInput and clears the pending prompt', () async {
|
||||
proc.emit(canUseTool('req-2'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final p = session.pendingPrompt!;
|
||||
session.resolvePrompt(p.promptId, AllowTool(p.input));
|
||||
expect(session.pendingPrompt, isNull);
|
||||
|
||||
final sent = jsonDecode(proc.writes.single) as Map<String, dynamic>;
|
||||
expect(sent['type'], 'control_response');
|
||||
final resp = sent['response'] as Map;
|
||||
expect(resp['subtype'], 'success');
|
||||
expect(resp['request_id'], 'req-2');
|
||||
final decision = resp['response'] as Map;
|
||||
expect(decision['behavior'], 'allow');
|
||||
expect((decision['updatedInput'] as Map)['content'], 'banana');
|
||||
});
|
||||
|
||||
test('resolvePrompt(deny) writes a deny decision with a message', () async {
|
||||
proc.emit(canUseTool('req-3'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
session.resolvePrompt('req-3', const DenyTool('nope'));
|
||||
final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map;
|
||||
expect(decision['behavior'], 'deny');
|
||||
expect(decision['message'], 'nope');
|
||||
});
|
||||
|
||||
test('prompts queue: resolving the head surfaces the next', () async {
|
||||
proc.emit(canUseTool('q1'));
|
||||
proc.emit(canUseTool('q2'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(session.pendingPrompt!.promptId, 'q1');
|
||||
session.resolvePrompt('q1', AllowTool(const {}));
|
||||
expect(session.pendingPrompt!.promptId, 'q2');
|
||||
session.resolvePrompt('q2', AllowTool(const {}));
|
||||
expect(session.pendingPrompt, isNull);
|
||||
});
|
||||
|
||||
test('resolvePrompt is a no-op for an unknown / already-resolved id', () async {
|
||||
proc.emit(canUseTool('req-4'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
session.resolvePrompt('req-4', AllowTool(const {})); // resolves
|
||||
session.resolvePrompt('req-4', AllowTool(const {})); // already gone
|
||||
session.resolvePrompt('does-not-exist', AllowTool(const {}));
|
||||
expect(proc.writes, hasLength(1));
|
||||
});
|
||||
|
||||
test('an unsupported control_request is answered with an error (no hang)', () async {
|
||||
proc.emit(jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'req-5',
|
||||
'request': {'subtype': 'mystery_subtype'},
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(items, isEmpty);
|
||||
final resp = (jsonDecode(proc.writes.single) as Map)['response'] as Map;
|
||||
expect(resp['subtype'], 'error');
|
||||
expect(resp['request_id'], 'req-5');
|
||||
expect(resp['error'], contains('mystery_subtype'));
|
||||
});
|
||||
|
||||
test('dispose kills the process', () async {
|
||||
await session.dispose();
|
||||
expect(proc.killed, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user