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:
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Native permission & AskUserQuestion prompts (T-166, D-78) — when Claude
|
||||
needs tool approval or asks a question, an inline card appears in the
|
||||
conversation with Allow/Deny or selectable options; the decision is
|
||||
returned over the stream-json control channel. Closes the prompt gap
|
||||
the tmux model couldn't surface.
|
||||
- Conversation message cards (T-173) — every turn in the Claude pane now
|
||||
renders through one card template with a copy button on hover and a
|
||||
collapse/expand caret for tool calls, results, and thinking.
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'claude_banner.dart';
|
||||
@@ -14,14 +12,19 @@ import 'claude_status.dart';
|
||||
import 'clipboard_paste.dart';
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'prompt_card.dart';
|
||||
import 'session_index.dart';
|
||||
import 'session_naming.dart';
|
||||
import 'session_picker.dart';
|
||||
import 'slash_commands.dart';
|
||||
import 'tmux_session.dart' as tmux;
|
||||
import 'transcript_publisher.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'transcript_reader.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
||||
/// feed the [ConversationController], permission / AskUserQuestion prompts come
|
||||
/// back as [ToolPrompt] cards the user answers, and input is written to the
|
||||
/// process stdin. No tmux — `--resume` (D-77) provides session continuity.
|
||||
class ClaudePane extends StatefulWidget {
|
||||
const ClaudePane({
|
||||
super.key,
|
||||
@@ -50,27 +53,16 @@ class ClaudePane extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
// Fixed tmux window size — Claude's TUI is no longer rendered (we read
|
||||
// its transcript instead, T-137/D-75), so a sane default is enough to
|
||||
// keep claude's layout happy inside the headless tmux session.
|
||||
static const _cols = 120;
|
||||
static const _rows = 40;
|
||||
static String? _tmuxConfPath;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
ConversationController? _conversation;
|
||||
TranscriptPublisher? _feed;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _sessionId;
|
||||
String? _repoRoot;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
String _statusLine = 'starting…';
|
||||
|
||||
bool _spawned = false;
|
||||
bool _usingTmux = false;
|
||||
|
||||
// The status line surfaced to the bottom status bar via ClidePane — the
|
||||
// live session fields (model/mode/context, T-150) plus the configured
|
||||
@@ -100,8 +92,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Spawn once, after the kernel is available. The conversation renders
|
||||
// from the transcript, so we no longer wait on a terminal resize.
|
||||
// Spawn once, after the kernel is available.
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
unawaited(_spawnWhenReady());
|
||||
@@ -116,52 +107,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void dispose() {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_conversation?.dispose();
|
||||
_conversation = null;
|
||||
unawaited(_feed?.dispose());
|
||||
_feed = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
final sessionName = _sessionName;
|
||||
_paneId = null;
|
||||
// Secondary panes own their tmux session — close on dispose.
|
||||
// Primary panes leave the tmux session alive so the next launch
|
||||
// re-attaches via `tmux new-session -A` (D-41).
|
||||
//
|
||||
// pane.close kills the PTY-spawned tmux *client*; the tmux server
|
||||
// keeps the session alive. We need an explicit kill-session for
|
||||
// secondaries to actually disappear (D-41 close semantics).
|
||||
if (id != null && !widget.isPrimary) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
if (sessionName != null) {
|
||||
unawaited(tmux.killSession(sessionName));
|
||||
}
|
||||
}
|
||||
// The controller's onDispose kills the session (process + streams).
|
||||
_conversation?.dispose();
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- tmux config extraction -----------------------------------------------
|
||||
|
||||
static Future<String?> _ensureTmuxConf() async {
|
||||
if (_tmuxConfPath != null) return _tmuxConfPath;
|
||||
try {
|
||||
final content = await rootBundle.loadString('assets/clide.tmux.conf');
|
||||
final dir = Directory(
|
||||
'${Platform.environment['HOME'] ?? '/tmp'}/.config/clide',
|
||||
);
|
||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||
final file = File('${dir.path}/tmux.conf');
|
||||
file.writeAsStringSync(content);
|
||||
_tmuxConfPath = file.path;
|
||||
return _tmuxConfPath;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- spawn ----------------------------------------------------------------
|
||||
|
||||
Future<void> _spawnWhenReady() async {
|
||||
@@ -197,133 +151,44 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
_repoRoot = repoRoot;
|
||||
|
||||
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
// Bind this pane to a specific Claude session id so concurrent
|
||||
// sessions in one workspace don't collide on the newest transcript
|
||||
// (T-146). Primary: deterministic → resumes across restarts.
|
||||
// Secondary: fresh → always a clean session.
|
||||
// Bind this pane to a specific session id (T-146). Primary: deterministic
|
||||
// → resumes across restarts. Secondary: fresh → a clean session.
|
||||
_sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId();
|
||||
|
||||
// A transcript already on disk means the session existed before, so resume
|
||||
// it; `claude --session-id <id>` refuses an existing id (T-161/D-77).
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl';
|
||||
final resume = await File(transcriptFile).exists();
|
||||
final sessionArgs = claudeLaunchArgs(_sessionId!, resume: resume);
|
||||
|
||||
// A transcript already on disk means this session existed before, so we
|
||||
// resume it; otherwise it's new. This drives both the self-heal kill and
|
||||
// the launch flag — `claude --session-id <id>` REFUSES an existing id
|
||||
// ("already in use"), so an existing session must launch with `--resume`
|
||||
// (T-161).
|
||||
final transcriptExists = await File(transcriptFile).exists();
|
||||
|
||||
// Self-heal (T-147): if no transcript is bound to our session id, any
|
||||
// clide tmux session of this name is stale (created before session-id
|
||||
// binding, or otherwise unconnectable) and `new-session -A` would
|
||||
// attach to it and leave the pane stuck waiting forever. Kill it so a
|
||||
// clean session is created. Safe by construction: only ever kills clide's
|
||||
// OWN `clide-claude-<slug>` session on the private `-L clide` socket, and
|
||||
// never deletes any transcript. A healthy session's transcript exists, so
|
||||
// re-attach (D-41 continuity) is preserved.
|
||||
if (!transcriptExists) {
|
||||
await tmux.killSession(_sessionName!);
|
||||
}
|
||||
|
||||
final launch = claudeLaunchArgs(_sessionId!, resume: transcriptExists);
|
||||
final tmuxConf = await _ensureTmuxConf();
|
||||
const cols = _cols;
|
||||
const rows = _rows;
|
||||
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'-L',
|
||||
'clide',
|
||||
if (tmuxConf != null) ...['-f', tmuxConf],
|
||||
'new-session',
|
||||
'-A',
|
||||
'-s',
|
||||
_sessionName!,
|
||||
'-x',
|
||||
'$cols',
|
||||
'-y',
|
||||
'$rows',
|
||||
...launch,
|
||||
];
|
||||
|
||||
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
|
||||
// input box pinned to the bottom of the alt-screen, claude owns
|
||||
// its own scrollback. Removes the need for tmux scroll forwarding.
|
||||
final env = {'CLAUDE_CODE_NO_FLICKER': '1'};
|
||||
|
||||
var resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
'title': _sessionName,
|
||||
'env': env,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
argv = launch;
|
||||
resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
'title': _sessionName,
|
||||
'env': env,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() => _error = resp.error?.message ?? 'spawn failed');
|
||||
final StreamJsonSession session;
|
||||
try {
|
||||
final proc = await ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: repoRoot);
|
||||
session = StreamJsonSession(proc)..start();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start claude: $e');
|
||||
return;
|
||||
}
|
||||
_usingTmux = false;
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
} else {
|
||||
_usingTmux = true;
|
||||
setState(() => _statusLine = 'tmux · $_sessionName');
|
||||
if (!mounted) {
|
||||
await session.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_paneId = resp.data['id'] as String?;
|
||||
// Render the conversation natively from the transcript (T-137/D-75)
|
||||
// rather than the PTY's TUI output. claude runs in tmux; a reader
|
||||
// tails its transcript JSONL and a publisher fans the items onto the
|
||||
// kernel MessageBus, which the view's controller subscribes to. The
|
||||
// subscription is wired before the reader's first poll so the initial
|
||||
// tail is never missed.
|
||||
// Tail this session's own transcript (<munged-cwd>/<sessionId>.jsonl),
|
||||
// not just the newest in the workspace — that's what kept secondaries
|
||||
// showing the primary's conversation (T-146). Each pane gets its own
|
||||
// bus channel so their controllers don't cross-talk.
|
||||
final messages = _kernel()!.messages;
|
||||
final channel = ClaudeConversation.sessionChannel(_sessionId!);
|
||||
_feed = TranscriptPublisher(
|
||||
messages: messages,
|
||||
reader: TranscriptReader(repoRoot, file: transcriptFile),
|
||||
channel: channel,
|
||||
);
|
||||
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
|
||||
// On status change, rebuild — ClidePane re-conveys the new statusWidget
|
||||
// to the bar while this pane is focused (T-150).
|
||||
_statusSub = _feed!.statusStream.listen((s) {
|
||||
_session = session;
|
||||
_conversation = ConversationController(stream: session.items, onDispose: session.dispose);
|
||||
_statusSub = session.statusStream.listen((s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
});
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
||||
}
|
||||
|
||||
// Send composed text to Claude. On the tmux path, submit via the tmux
|
||||
// server (paste-buffer + Enter) — it reaches Claude even with no client
|
||||
// attached, unlike pane.write to the (now-detached) spawned client PTY.
|
||||
// The no-tmux fallback runs claude directly in our PTY, where pane.write
|
||||
// does reach it.
|
||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||
// owns (T-156) are handled here, never forwarded — /clear and /resume fork
|
||||
// the session to a new id, so clide drives them: /clear starts fresh,
|
||||
// /resume picks a past session and re-binds to it.
|
||||
void _send(String text) {
|
||||
// Commands clide owns (T-156) are handled here, never forwarded — Claude
|
||||
// Code's /clear and /resume fork the session to a new id our reader can't
|
||||
// follow, so clide drives them: /clear starts fresh, /resume picks a past
|
||||
// session and re-binds to it.
|
||||
switch (clideOwnedCommand(text)) {
|
||||
case 'clear':
|
||||
unawaited(_clearSession());
|
||||
@@ -332,37 +197,17 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
unawaited(_resumeFlow());
|
||||
return;
|
||||
}
|
||||
if (_usingTmux) {
|
||||
final session = _sessionName;
|
||||
if (session == null) return;
|
||||
// Recognised slash commands go typed (so the TUI fires them); anything
|
||||
// else is bracketed-pasted, keeping multi-line text and stray leading
|
||||
// slashes literal (T-153).
|
||||
final known = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
|
||||
if (isKnownSlashCommand(text, known)) {
|
||||
unawaited(tmux.sendCommand(session, text));
|
||||
} else {
|
||||
unawaited(tmux.sendMessage(session, text));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final id = _paneId;
|
||||
final ipc = _ipc();
|
||||
if (id == null || ipc == null) return;
|
||||
unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)}));
|
||||
_session?.send(text);
|
||||
}
|
||||
|
||||
/// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty
|
||||
/// session. A fresh id is forced — even for the primary, whose id is normally
|
||||
/// deterministic — so we start empty rather than resume the old transcript.
|
||||
/// clide-owned `/clear` (T-156): respawn on a brand-new, empty session.
|
||||
Future<void> _clearSession() async {
|
||||
if (mounted) setState(() => _statusLine = 'clearing…');
|
||||
await _respawnWithSession(freshSessionId());
|
||||
}
|
||||
|
||||
/// clide-owned `/resume` (T-156): pick a past session for this workspace and
|
||||
/// re-bind the pane to it. Claude Code's own /resume forks to a session our
|
||||
/// reader can't follow, so clide drives the switch.
|
||||
/// re-bind the pane to it.
|
||||
Future<void> _resumeFlow() async {
|
||||
final root = _repoRoot;
|
||||
final dialog = _kernel()?.dialog;
|
||||
@@ -383,49 +228,19 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
await _respawnWithSession(picked);
|
||||
}
|
||||
|
||||
/// Tear the current session down and respawn the pane bound to [sessionId].
|
||||
/// The tmux session is killed first so `new-session` starts a fresh client
|
||||
/// on the new id rather than re-attaching the still-running old claude; the
|
||||
/// old transcript is left on disk (history preserved, detached).
|
||||
/// Tear the current session down and respawn bound to [sessionId]. The old
|
||||
/// process is killed; its transcript stays on disk (history preserved).
|
||||
Future<void> _respawnWithSession(String sessionId) async {
|
||||
_conversation?.dispose();
|
||||
_conversation = null;
|
||||
unawaited(_feed?.dispose());
|
||||
_feed = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final old = _sessionName;
|
||||
if (old != null) await tmux.killSession(old);
|
||||
_conversation?.dispose(); // onDispose kills the old session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
_sessionId = sessionId;
|
||||
if (mounted) setState(() => _status = const SessionStatus());
|
||||
await _spawn();
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
// Lifecycle only — content comes from the transcript, not pane.output.
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) async {
|
||||
if (e.subsystem != 'pane' || e.data['id'] != _paneId) return;
|
||||
switch (e.kind) {
|
||||
case 'pane.exit':
|
||||
// A transient tmux client can exit (e.g. during spawn/respawn)
|
||||
// while the session — and Claude — stay alive. Don't report
|
||||
// "exited" then; only when the tmux session is actually gone.
|
||||
// (The no-tmux fallback has no session, so the exit is real.)
|
||||
if (_usingTmux && _sessionName != null && await tmux.hasSession(_sessionName!)) {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited');
|
||||
case 'pane.closed':
|
||||
_paneId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
@@ -464,29 +279,34 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
),
|
||||
),
|
||||
ClaudeComposer(
|
||||
enabled: _paneId != null,
|
||||
// The composer zone: an open prompt (permission / AskUserQuestion)
|
||||
// takes this space and hides the text input until it's answered, so
|
||||
// interaction stays out of the conversation stream (D-78).
|
||||
StreamBuilder<ToolPrompt?>(
|
||||
stream: _session?.pendingPromptStream,
|
||||
initialData: _session?.pendingPrompt,
|
||||
builder: (context, snap) {
|
||||
final prompt = snap.data;
|
||||
if (prompt != null && _session != null) {
|
||||
return ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt);
|
||||
}
|
||||
return ClaudeComposer(
|
||||
enabled: _session != null,
|
||||
onSubmit: _send,
|
||||
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
body = const Center(child: ClideText('attaching…', muted: true));
|
||||
body = const Center(child: ClideText('starting…', muted: true));
|
||||
}
|
||||
|
||||
final content = widget.showChrome
|
||||
? ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
onClose: widget.isPrimary
|
||||
? null
|
||||
: () {
|
||||
final id = _paneId;
|
||||
if (id != null) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/// The interactive prompt surface for the stream-json control channel
|
||||
/// (T-166, D-78): a permission Allow/Deny for a gated tool, or an
|
||||
/// `AskUserQuestion` option picker. Rendered in the composer zone (not inline
|
||||
/// in the conversation) so interaction and conversation widgets don't mix —
|
||||
/// the pane swaps it in for the text input while a prompt is open. The decision
|
||||
/// is returned via [onResolve]; the pane then removes the card.
|
||||
///
|
||||
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
|
||||
/// hover-revealed chrome that would fight the buttons.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ToolPromptCard extends StatefulWidget {
|
||||
const ToolPromptCard({super.key, required this.prompt, required this.onResolve});
|
||||
|
||||
final ToolPrompt prompt;
|
||||
|
||||
/// Called once with the user's decision; the card binds the prompt id.
|
||||
final void Function(String promptId, ToolDecision decision) onResolve;
|
||||
|
||||
@override
|
||||
State<ToolPromptCard> createState() => _ToolPromptCardState();
|
||||
}
|
||||
|
||||
class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
// AskUserQuestion: per-question chosen option labels (set = multi-select).
|
||||
late List<Set<String>> _picked = List.generate(_questions.length, (_) => <String>{});
|
||||
|
||||
List<_Question> get _questions => _parseQuestions(widget.prompt.input);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ToolPromptCard old) {
|
||||
super.didUpdateWidget(old);
|
||||
// A different prompt rotated into the same slot — reset selections.
|
||||
if (old.prompt.promptId != widget.prompt.promptId) {
|
||||
_picked = List.generate(_questions.length, (_) => <String>{});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final isQuestion = widget.prompt.isQuestion;
|
||||
final accent = isQuestion ? tokens.statusInfo : tokens.statusWarning;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border(top: BorderSide(color: accent, width: 2)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
isQuestion ? 'question' : 'permission · ${widget.prompt.displayName}',
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: accent,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (isQuestion) ..._questionBody(tokens) else ..._permissionBody(tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- permission allow/deny -------------------------------------------------
|
||||
|
||||
List<Widget> _permissionBody(SurfaceTokens tokens) {
|
||||
final desc = widget.prompt.description;
|
||||
return [
|
||||
if (desc != null && desc.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText(desc, fontSize: clideFontMeta, color: tokens.globalForeground),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(
|
||||
label: 'Allow',
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: () => widget.onResolve(widget.prompt.promptId, AllowTool(widget.prompt.input)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: 'Deny',
|
||||
onPressed: () => widget.onResolve(widget.prompt.promptId, const DenyTool('Denied by the user.')),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// -- AskUserQuestion option picker ----------------------------------------
|
||||
|
||||
List<Widget> _questionBody(SurfaceTokens tokens) {
|
||||
final questions = _questions;
|
||||
final answered = questions.asMap().entries.every((e) => _picked[e.key].isNotEmpty);
|
||||
return [
|
||||
for (final (qi, q) in questions.indexed) _questionBlock(tokens, qi, q),
|
||||
const SizedBox(height: 6),
|
||||
ClideButton(
|
||||
label: 'Submit',
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: answered ? () => _submitAnswers(questions) : null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _questionBlock(SurfaceTokens tokens, int qi, _Question q) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (q.header.isNotEmpty) ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: ClideText(q.question, color: tokens.globalForeground),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final opt in q.options)
|
||||
ClideButton(
|
||||
label: _picked[qi].contains(opt.label) ? '● ${opt.label}' : '○ ${opt.label}',
|
||||
variant: _picked[qi].contains(opt.label) ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
|
||||
tooltip: opt.description.isNotEmpty ? opt.description : null,
|
||||
onPressed: () => _toggle(qi, q, opt.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggle(int qi, _Question q, String label) {
|
||||
setState(() {
|
||||
final sel = _picked[qi];
|
||||
if (q.multiSelect) {
|
||||
sel.contains(label) ? sel.remove(label) : sel.add(label);
|
||||
} else {
|
||||
sel
|
||||
..clear()
|
||||
..add(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _submitAnswers(List<_Question> questions) {
|
||||
// answers: question text → chosen label(s), comma-separated for multi (D-78).
|
||||
final answers = <String, String>{};
|
||||
for (final (qi, q) in questions.indexed) {
|
||||
answers[q.question] = _picked[qi].join(', ');
|
||||
}
|
||||
widget.onResolve(widget.prompt.promptId, AllowTool({...widget.prompt.input, 'answers': answers}));
|
||||
}
|
||||
}
|
||||
|
||||
// -- AskUserQuestion input parsing -------------------------------------------
|
||||
|
||||
class _Question {
|
||||
_Question(this.question, this.header, this.multiSelect, this.options);
|
||||
final String question;
|
||||
final String header;
|
||||
final bool multiSelect;
|
||||
final List<_Option> options;
|
||||
}
|
||||
|
||||
class _Option {
|
||||
_Option(this.label, this.description);
|
||||
final String label;
|
||||
final String description;
|
||||
}
|
||||
|
||||
List<_Question> _parseQuestions(Map<String, dynamic> input) {
|
||||
final raw = input['questions'];
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final q in raw)
|
||||
if (q is Map)
|
||||
_Question(
|
||||
q['question'] as String? ?? '',
|
||||
q['header'] as String? ?? '',
|
||||
q['multiSelect'] as bool? ?? false,
|
||||
[
|
||||
for (final o in (q['options'] as List? ?? const []))
|
||||
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -68,11 +68,12 @@ String _hash(String s) {
|
||||
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
|
||||
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
|
||||
|
||||
/// The `claude` argv to launch [sessionId]: `--resume` an existing session,
|
||||
/// or `--session-id` to create a new one. `--session-id` REFUSES an id that
|
||||
/// already exists ("Session ID … is already in use") — so resuming a pane
|
||||
/// whose transcript already exists must use `--resume` (T-161).
|
||||
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['claude', '--resume', sessionId] : ['claude', '--session-id', sessionId];
|
||||
/// The session-selection args for launching [sessionId]: `--resume` an
|
||||
/// existing session, or `--session-id` to create a new one. `--session-id`
|
||||
/// REFUSES an id that already exists ("Session ID … is already in use") — so
|
||||
/// resuming a pane whose transcript already exists must use `--resume`
|
||||
/// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess].
|
||||
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId];
|
||||
|
||||
/// A fresh random session id for a secondary pane — secondaries are
|
||||
/// always clean sessions, never resumed.
|
||||
|
||||
@@ -53,6 +53,11 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
// Route permission asks + AskUserQuestion to us over the control
|
||||
// channel as `can_use_tool` requests. Without `stdio` the CLI silently
|
||||
// auto-denies anything needing approval (D-78).
|
||||
'--permission-prompt-tool',
|
||||
'stdio',
|
||||
...sessionArgs,
|
||||
],
|
||||
workingDirectory: cwd,
|
||||
@@ -73,8 +78,69 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||
/// [StreamJsonSession.resolvePrompt]. Not a conversation item — prompts are
|
||||
/// *interaction*, surfaced in the composer zone, not the transcript (D-78).
|
||||
class ToolPrompt {
|
||||
const ToolPrompt({
|
||||
required this.promptId,
|
||||
required this.toolName,
|
||||
required this.displayName,
|
||||
required this.input,
|
||||
this.description,
|
||||
this.toolUseId = '',
|
||||
});
|
||||
|
||||
/// The control_request `request_id` — the key passed to [StreamJsonSession.resolvePrompt].
|
||||
final String promptId;
|
||||
|
||||
/// The gated `tool_use_id` (matches the preceding assistant tool_use).
|
||||
final String toolUseId;
|
||||
|
||||
/// Tool being requested, e.g. `Write` or `AskUserQuestion`.
|
||||
final String toolName;
|
||||
|
||||
/// Human label (`display_name`), falls back to [toolName].
|
||||
final String displayName;
|
||||
|
||||
/// Optional one-line summary (`description`).
|
||||
final String? description;
|
||||
|
||||
/// The tool's proposed input — echoed back (possibly modified) on allow.
|
||||
final Map<String, dynamic> input;
|
||||
|
||||
/// AskUserQuestion is answered through the same channel (D-78).
|
||||
bool get isQuestion => toolName == 'AskUserQuestion';
|
||||
}
|
||||
|
||||
/// A decision returned for a [ToolPrompt] over the control channel (D-78).
|
||||
sealed class ToolDecision {
|
||||
const ToolDecision();
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
/// Allow the tool. [updatedInput] is REQUIRED by the protocol — pass the
|
||||
/// request's input unchanged to allow as-is, or modified to alter the call.
|
||||
/// For AskUserQuestion, include the `answers` map (question text → label).
|
||||
final class AllowTool extends ToolDecision {
|
||||
const AllowTool(this.updatedInput);
|
||||
final Map<String, dynamic> updatedInput;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'allow', 'updatedInput': updatedInput};
|
||||
}
|
||||
|
||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||
final class DenyTool extends ToolDecision {
|
||||
const DenyTool(this.message);
|
||||
final String message;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
||||
}
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// and sends user messages.
|
||||
/// answers control-channel prompts, and sends user messages.
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc);
|
||||
|
||||
@@ -85,6 +151,18 @@ class StreamJsonSession {
|
||||
SessionStatus _status = const SessionStatus();
|
||||
int _localSeq = 0;
|
||||
|
||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||
/// is the one currently shown in the composer zone.
|
||||
final _queue = <ToolPrompt>[];
|
||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
||||
|
||||
/// The prompt currently awaiting a decision (queue head), or null.
|
||||
ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first;
|
||||
|
||||
/// Emits the current pending prompt (or null) whenever it changes — the
|
||||
/// composer zone swaps between the prompt UI and the text input on this.
|
||||
Stream<ToolPrompt?> get pendingPromptStream => _pendingCtl.stream;
|
||||
|
||||
/// Conversation items, in arrival order (assistant turns + the local echo
|
||||
/// of the user's own messages).
|
||||
Stream<ConversationItem> get items => _items.stream;
|
||||
@@ -100,6 +178,18 @@ class StreamJsonSession {
|
||||
void _onLine(String line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
|
||||
final Map<String, dynamic> ev;
|
||||
try {
|
||||
ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
// Control-channel requests (permission asks, AskUserQuestion) must be
|
||||
// routed out of the normal event stream and answered (D-78).
|
||||
if (ev['type'] == 'control_request') {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// Items + assistant model/tokens reuse the transcript parser (identical
|
||||
// message.content shapes).
|
||||
final parsed = parseTranscriptChunk(trimmed);
|
||||
@@ -108,17 +198,51 @@ class StreamJsonSession {
|
||||
}
|
||||
// The `init` event carries permission mode (no `permission-mode` record
|
||||
// exists in stream-json); fold it in alongside the parsed deltas.
|
||||
_mergeStatus(parsed.status.merge(_statusFromEvent(trimmed)));
|
||||
_mergeStatus(parsed.status.merge(_statusFromEvent(ev)));
|
||||
}
|
||||
|
||||
SessionStatus _statusFromEvent(String line) {
|
||||
Object? j;
|
||||
try {
|
||||
j = jsonDecode(line);
|
||||
} catch (_) {
|
||||
return const SessionStatus();
|
||||
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// the turn never hangs waiting on us (D-78).
|
||||
void _onControlRequest(Map<String, dynamic> ev) {
|
||||
final rid = ev['request_id'] as String?;
|
||||
final request = ev['request'];
|
||||
if (rid == null || request is! Map) return;
|
||||
if (request['subtype'] == 'can_use_tool') {
|
||||
final toolName = request['tool_name'] as String? ?? '';
|
||||
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
||||
_queue.add(ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
description: request['description'] as String?,
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
));
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
}
|
||||
if (j is! Map) return const SessionStatus();
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}));
|
||||
}
|
||||
|
||||
/// Answer a [ToolPrompt] over the control channel, by its
|
||||
/// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the
|
||||
/// queue so the next pending prompt (if any) surfaces.
|
||||
void resolvePrompt(String promptId, ToolDecision decision) {
|
||||
final before = _queue.length;
|
||||
_queue.removeWhere((p) => p.promptId == promptId);
|
||||
if (_queue.length == before) return; // unknown / already resolved
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
}
|
||||
|
||||
SessionStatus _statusFromEvent(Map<String, dynamic> j) {
|
||||
if (j['type'] == 'system' && j['subtype'] == 'init') {
|
||||
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
|
||||
}
|
||||
@@ -155,5 +279,6 @@ class StreamJsonSession {
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _pendingCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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