refine the prompt/log UX: show commands, de-emphasize injects

Two spot-check fixes (T-178, T-179), both grounded in a boundary test of
the stream-json wire (findings folded into the spike doc):

- Harness-injected user messages (skill loads, slash-command expansions,
  system reminders) carry isSynthetic on the wire (isMeta in the
  transcript). They were rendering as blue "you" cards though the user
  never typed them; now UserMessage.injected flags them and the view
  shows a muted, collapsed "context" card instead.
- Permission prompts now show the command/input being permitted (a
  capped, scrollable code block) so you can see what you approve. Instead
  of fully hiding a prompted tool-use, once resolved it collapses to a
  one-line summary with a green (approved) or red (denied) border; the
  session tracks per-tool_use_id outcome and the view colours it. The
  result is kept.

Corrects an earlier wrong assumption: the Skill tool is auto-allowed
(no permission prompt); the inject only appears once the Skill tool is
actually invoked, which is why deny-captures missed it.

T-178, T-179, D-78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:26:33 +02:00
co-authored by Claude Opus 4.7
parent c30a707b41
commit 2243237b13
12 changed files with 189 additions and 32 deletions
@@ -142,6 +142,36 @@ void main() {
expect(echoed.single.text, 'do the thing');
});
test('a synthetic user message (skill/command inject) is flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'}
],
},
}));
await Future<void>.delayed(Duration.zero);
final u = items.whereType<UserMessage>().single;
expect(u.injected, isTrue);
});
test('a plain user text event is not flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'}
],
},
}));
await Future<void>.delayed(Duration.zero);
expect(items.whereType<UserMessage>().single.injected, isFalse);
});
test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async {
final emitted = <ToolPrompt?>[];
session.pendingPromptStream.listen(emitted.add);
@@ -223,6 +253,20 @@ void main() {
expect((follow['message'] as Map)['content'], 'use docs/ instead');
});
test('resolvePrompt records the tool outcome — allow', () async {
proc.emit(canUseTool('o1'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt('o1', AllowTool(const {}));
expect(session.toolUseOutcomes['toolu_1'], isTrue);
});
test('resolvePrompt records the tool outcome — deny', () async {
proc.emit(canUseTool('o2'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt('o2', const DenyTool('no'));
expect(session.toolUseOutcomes['toolu_1'], isFalse);
});
test('resolvePrompt(deny) writes a deny decision with a message', () async {
proc.emit(canUseTool('req-3'));
await Future<void>.delayed(Duration.zero);