add an interrupt path for a running Claude turn
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s
A runaway turn had no escape: Escape was unbound once the slash typeahead
was closed, and there was no Stop affordance. Now the composer interrupts
the in-flight turn — Escape (when no typeahead is open) or a Stop button
shown while busy — over the stream-json control channel.
StreamJsonSession gains interrupt() (writes a {subtype: interrupt}
control_request; claude cancels the turn and ends it with a result) and a
busy/busyStream signal driven true on send and false on the next result.
The pane binds onInterrupt to the session and reflects busy reactively.
D-78.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,5 +231,66 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, ['just text']);
|
||||
});
|
||||
|
||||
testWidgets('Escape interrupts when the typeahead is closed', (tester) async {
|
||||
var interrupts = 0;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++),
|
||||
));
|
||||
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(interrupts, 1);
|
||||
});
|
||||
|
||||
testWidgets('Escape closes the typeahead before it interrupts', (tester) async {
|
||||
var interrupts = 0;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(
|
||||
onSubmit: (_) {},
|
||||
onInterrupt: () => interrupts++,
|
||||
slashCommandsResolver: () => ['model'],
|
||||
),
|
||||
));
|
||||
await tester.enterText(find.byType(EditableText), '/mo');
|
||||
await tester.pump();
|
||||
expect(find.text('/model'), findsOneWidget);
|
||||
|
||||
// First Escape only dismisses the popup; it does not interrupt.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.text('/model'), findsNothing);
|
||||
expect(interrupts, 0);
|
||||
|
||||
// A second Escape, now with the popup closed, interrupts.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(interrupts, 1);
|
||||
});
|
||||
|
||||
testWidgets('the Stop button shows when busy and interrupts on tap', (tester) async {
|
||||
var interrupts = 0;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++),
|
||||
));
|
||||
expect(find.text('Stop ⎋'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Stop ⎋'));
|
||||
await tester.pump();
|
||||
expect(interrupts, 1);
|
||||
});
|
||||
|
||||
testWidgets('no Stop button when idle', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {}),
|
||||
));
|
||||
expect(find.text('Stop ⎋'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -337,6 +337,26 @@ void main() {
|
||||
expect(resp['error'], contains('mystery_subtype'));
|
||||
});
|
||||
|
||||
test('interrupt writes an interrupt control_request', () async {
|
||||
session.interrupt();
|
||||
final sent = jsonDecode(proc.writes.single) as Map<String, dynamic>;
|
||||
expect(sent['type'], 'control_request');
|
||||
expect((sent['request'] as Map)['subtype'], 'interrupt');
|
||||
expect(sent['request_id'], isNotNull);
|
||||
});
|
||||
|
||||
test('busy goes true on send and false on a result event', () async {
|
||||
final busy = <bool>[];
|
||||
session.busyStream.listen(busy.add);
|
||||
session.send('hi');
|
||||
expect(session.busy, isTrue);
|
||||
|
||||
proc.emit(jsonEncode({'type': 'result', 'subtype': 'success'}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(session.busy, isFalse);
|
||||
expect(busy, [true, false]);
|
||||
});
|
||||
|
||||
test('dispose kills the process', () async {
|
||||
await session.dispose();
|
||||
expect(proc.killed, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user