Spaces:
Running
Reader: show the prompts typed while the agent was working
Browse files> i can't see the last two prompts (api feedback, screenshot bug report) in the
> reader
Confirmed against the transcript named in the report. A prompt typed mid-turn is
not written as a `user` message: Claude Code records
{"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}
and the text exists nowhere else. `queue-operation` appeared nowhere in this
repo, so those prompts were invisible in the reader — permanently, not late.
Two corrections to the diagnosis, both from the data, because they change the
fix:
**There are three operations, not two.** 91 `enqueue`, 86 `dequeue`, 5 `remove`
in the reference transcript. `dequeue` carries no content and means the prompt
was popped into a request — it then arrives as an ordinary user message, and 40
of 45 distinct enqueued texts do exactly that. `remove` names its text and means
nothing else will carry it: those 5 are the invisible ones, and 3 of them are
verifiably prompts the operator sent (the meta-row bug I was handed two rounds
ago is one). So the fix cannot be "render every enqueue" — that double-renders
the common path. My first attempt matched enqueued text against later messages
instead, and it showed one prompt six times: a window held six enqueues of a
looped self-prompt and two of its messages. Deciding from the records themselves
is window-safe, because enqueue and its dequeue/remove sit seconds apart.
**One of the five removes is an enqueued `<task-notification>`.** Queued harness
noise is still harness noise, so the same filter ordinary user text gets applies
here: `<…>` and `[Request interrupted` are not prompts.
The rule, then: emit a queued prompt, and drop it again unless a `remove` proves
it is the only copy. A prompt still queued when the window ends is left to its
message for the same reason.
**Where it goes.** Where it was typed — inside the turn it interrupted, so it
opens a new exchange there and that turn keeps only the work it had done. That is
the order it happened in, and the answer that follows usually addresses it. The
alternative, holding it until the turn ends, would place it after work it
predates — the bug #85 was about.
**And the band says `queued`,** because the records cannot distinguish a prompt
that was consumed from one that was cancelled. All 5 removes here were real, and
no cancellation appears in 5,287 lines, but the label means a cancelled prompt
would read as "typed and queued" rather than as one the agent answered. If Claude
Code ever distinguishes them, the label is where that goes.
Checked live, not only on load: appending an enqueue+remove pair to a transcript
under an open reader has the prompt appear on the next poll (bands 2 → 3).
`server/test/queued-prompts.test.mjs` pins all of it: the removed path is shown
and labelled, the dequeued path is shown once by its real message, both paths in
one turn keep their order, the same words queued twice keep one copy each, an
enqueued `<task-notification>` is not a prompt, and a still-queued prompt waits.
Mutation-checked — ignoring the records fails 5, showing every enqueue fails 3,
dropping the noise filter fails 1.
Left alone deliberately: the digest (`scanClaude`) still counts only real user
messages, so the Overview card's "last prompt" can lag a queued one. Same
records, different surface, and worth its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- docs/conversation-view.md +22 -0
- server/package.json +1 -1
- server/src/traces.js +61 -0
- server/test/queued-prompts.test.mjs +154 -0
- web/src/api.ts +8 -0
- web/src/components/conversation/Exchange.tsx +9 -1
- web/src/conversation.css +5 -0
|
@@ -435,6 +435,28 @@ pane with nothing to render (a shell) simply stays a terminal.
|
|
| 435 |
history recall, a slash-command menu — and duplicated markup is how one surface quietly gets
|
| 436 |
them and the other does not. `onPasteFiles` and `above` are the seam an attachment strip plugs
|
| 437 |
into, so it arrives in both places at once or in neither.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
- **A half-typed reply is kept** (`drafts.ts`, `useDraft.ts`). Reported from a phone: start typing
|
| 439 |
an answer, switch apps, come back, and the text is gone. The pane is not what loses it — App.tsx
|
| 440 |
keeps a dozen panes warm, so an in-app trip to the session list already survived — the
|
|
|
|
| 435 |
history recall, a slash-command menu — and duplicated markup is how one surface quietly gets
|
| 436 |
them and the other does not. `onPasteFiles` and `above` are the seam an attachment strip plugs
|
| 437 |
into, so it arrives in both places at once or in neither.
|
| 438 |
+
- **A prompt typed mid-turn is still a prompt.** Claude Code does not write one as a `user`
|
| 439 |
+
message: while the agent is working it records `{"type":"queue-operation","operation":"enqueue",
|
| 440 |
+
"content":"…"}`, and the text exists nowhere else until the queue is consumed. `queue-operation`
|
| 441 |
+
appeared nowhere in this repo, so every such prompt was invisible in the reader — permanently,
|
| 442 |
+
not late. Two of the operator's own prompts were lost that way.
|
| 443 |
+
|
| 444 |
+
The two consumption paths need opposite treatment, and they are distinguishable from the records
|
| 445 |
+
alone (which matters, because a window can hold the enqueue and not what follows it): `dequeue`
|
| 446 |
+
carries no text and pops the oldest queued prompt — the prompt arrives as an ordinary message
|
| 447 |
+
right after, so the queued copy stands down; `remove` names the text it takes out, and nothing
|
| 448 |
+
else will ever carry it, so that copy is what the reader shows. In the reference transcript that
|
| 449 |
+
split is 86 dequeues to 5 removes, and the 5 are exactly the prompts with no message anywhere.
|
| 450 |
+
A prompt still in the queue when the window ends is left to its message for the same reason.
|
| 451 |
+
|
| 452 |
+
It is shown where it was typed — inside the turn it interrupted, which means it opens a new
|
| 453 |
+
exchange there and the interrupted turn keeps only the work it had done so far. That is the
|
| 454 |
+
honest order: the operator typed it then, and the answer that follows usually addresses it. And
|
| 455 |
+
the band says **queued**, because the records cannot tell a prompt that was consumed from one
|
| 456 |
+
that was cancelled — so the reader states what it knows rather than implying the agent replied.
|
| 457 |
+
Harness noise is filtered exactly as it is for ordinary user text: one of those five removes is
|
| 458 |
+
an enqueued `<task-notification>`, and showing that as something the operator typed would be a
|
| 459 |
+
new bug in place of the old one.
|
| 460 |
- **A half-typed reply is kept** (`drafts.ts`, `useDraft.ts`). Reported from a phone: start typing
|
| 461 |
an answer, switch apps, come back, and the text is gone. The pane is not what loses it — App.tsx
|
| 462 |
keeps a dozen panes warm, so an in-app trip to the session list already survived — the
|
|
@@ -16,7 +16,7 @@
|
|
| 16 |
"test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
|
| 17 |
"test:screenshots": "node screenshot-input.test.mjs",
|
| 18 |
"test:mobile": "node mobile.test.mjs",
|
| 19 |
-
"test": "node ../scripts/run-suites.mjs"
|
| 20 |
},
|
| 21 |
"engines": {
|
| 22 |
"node": ">=20.19"
|
|
|
|
| 16 |
"test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
|
| 17 |
"test:screenshots": "node screenshot-input.test.mjs",
|
| 18 |
"test:mobile": "node mobile.test.mjs",
|
| 19 |
+
"test": "node test/queued-prompts.test.mjs && node ../scripts/run-suites.mjs"
|
| 20 |
},
|
| 21 |
"engines": {
|
| 22 |
"node": ">=20.19"
|
|
@@ -1012,13 +1012,66 @@ async function* rangeJsonLines(file, range) {
|
|
| 1012 |
// same usage. parseClaude() above dedupes by dropping repeats; a viewer must
|
| 1013 |
// instead MERGE them, or half the assistant text disappears. So: first line for
|
| 1014 |
// an id creates the message and owns the usage, later lines append blocks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
async function normalizeClaude(file, out, range) {
|
| 1016 |
const stitch = makeStitcher();
|
| 1017 |
const byMsgId = new Map();
|
|
|
|
| 1018 |
for await (const j of jsonLines(file, range)) {
|
| 1019 |
// These embed whole file contents; share.js drops them and so do we.
|
| 1020 |
if (j.type === 'file-history-snapshot' || j.type === 'file-history-delta') continue;
|
| 1021 |
if (j.isMeta || j.sourceToolUseID) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1022 |
if (j.type !== 'user' && j.type !== 'assistant') continue;
|
| 1023 |
|
| 1024 |
const m = j.message;
|
|
@@ -1083,6 +1136,14 @@ async function normalizeClaude(file, out, range) {
|
|
| 1083 |
if (id) byMsgId.set(id, msg);
|
| 1084 |
}
|
| 1085 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1086 |
}
|
| 1087 |
|
| 1088 |
// Codex — $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
|
|
|
|
| 1012 |
// same usage. parseClaude() above dedupes by dropping repeats; a viewer must
|
| 1013 |
// instead MERGE them, or half the assistant text disappears. So: first line for
|
| 1014 |
// an id creates the message and owns the usage, later lines append blocks.
|
| 1015 |
+
// A prompt typed while the agent is mid-turn is not written as a `user` message
|
| 1016 |
+
// at all. Claude Code queues it:
|
| 1017 |
+
// {"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}
|
| 1018 |
+
// and the text exists NOWHERE else until the queue is consumed. Two things then
|
| 1019 |
+
// happen, and they need opposite treatment:
|
| 1020 |
+
// - `dequeue` (86 of 91 in the reference transcript): the prompt arrives as an
|
| 1021 |
+
// ordinary user message afterwards, so the queued copy must give way to it
|
| 1022 |
+
// or the reader shows the same prompt twice;
|
| 1023 |
+
// - `remove` (5 of 91): it never arrives. The queue record is the only copy
|
| 1024 |
+
// there will ever be — which is why those prompts were invisible in the
|
| 1025 |
+
// reader, permanently, not late.
|
| 1026 |
+
// The two paths are distinguishable from the records alone, which matters
|
| 1027 |
+
// because a window can contain the enqueue and not the message that follows it:
|
| 1028 |
+
// `dequeue` carries no content and pops the oldest queued prompt (the real
|
| 1029 |
+
// message is coming, so the queued copy stands down), while `remove` names the
|
| 1030 |
+
// text it takes out (nothing else will carry it, so the queued copy is what the
|
| 1031 |
+
// reader gets). Text matching against later messages was the first attempt and
|
| 1032 |
+
// it double-rendered a prompt the operator had queued eight times in a loop:
|
| 1033 |
+
// the window held six enqueues and two of the messages.
|
| 1034 |
+
const queueKey = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
| 1035 |
+
// The same filter the rest of this file applies to user text: an enqueued
|
| 1036 |
+
// `<task-notification>` is the harness talking to itself, not something the
|
| 1037 |
+
// operator typed. One of the five removes in the reference transcript is exactly
|
| 1038 |
+
// that, and showing it as a prompt would be a new bug in place of the old one.
|
| 1039 |
+
const isHarnessText = (t) => t.startsWith('<') || t.startsWith('[Request interrupted');
|
| 1040 |
+
|
| 1041 |
async function normalizeClaude(file, out, range) {
|
| 1042 |
const stitch = makeStitcher();
|
| 1043 |
const byMsgId = new Map();
|
| 1044 |
+
const pending = []; // queued prompts, oldest first, until dequeued or removed
|
| 1045 |
for await (const j of jsonLines(file, range)) {
|
| 1046 |
// These embed whole file contents; share.js drops them and so do we.
|
| 1047 |
if (j.type === 'file-history-snapshot' || j.type === 'file-history-delta') continue;
|
| 1048 |
if (j.isMeta || j.sourceToolUseID) continue;
|
| 1049 |
+
if (j.type === 'queue-operation') {
|
| 1050 |
+
const text = queueKey(j.content);
|
| 1051 |
+
if (j.operation === 'enqueue') {
|
| 1052 |
+
// Harness noise is not a prompt: one of the five removes in the
|
| 1053 |
+
// reference transcript is an enqueued <task-notification>, and showing
|
| 1054 |
+
// that as something the operator typed would be a new bug for an old one.
|
| 1055 |
+
if (!text || isHarnessText(text)) continue;
|
| 1056 |
+
const msg = {
|
| 1057 |
+
role: 'user',
|
| 1058 |
+
ts: j.timestamp ? Date.parse(j.timestamp) || undefined : undefined,
|
| 1059 |
+
queued: true,
|
| 1060 |
+
blocks: [textBlock('text', String(j.content))],
|
| 1061 |
+
};
|
| 1062 |
+
out.push(msg);
|
| 1063 |
+
pending.push({ text, msg });
|
| 1064 |
+
msg.superseded = true; // until a `remove` proves it is the only copy
|
| 1065 |
+
} else if (j.operation === 'dequeue') {
|
| 1066 |
+
// popped into a request: the ordinary user message is on its way, and
|
| 1067 |
+
// that one keeps the prompt. Positional, because dequeue names no text.
|
| 1068 |
+
pending.shift();
|
| 1069 |
+
} else if (j.operation === 'remove') {
|
| 1070 |
+
const at = pending.findIndex((p) => p.text === text);
|
| 1071 |
+
if (at >= 0) pending.splice(at, 1)[0].msg.superseded = false;
|
| 1072 |
+
}
|
| 1073 |
+
continue;
|
| 1074 |
+
}
|
| 1075 |
if (j.type !== 'user' && j.type !== 'assistant') continue;
|
| 1076 |
|
| 1077 |
const m = j.message;
|
|
|
|
| 1136 |
if (id) byMsgId.set(id, msg);
|
| 1137 |
}
|
| 1138 |
}
|
| 1139 |
+
// Dropped at the end rather than never pushed: which path a queued prompt took
|
| 1140 |
+
// is not known until its dequeue or remove has been read. Anything still
|
| 1141 |
+
// pending when the window ends stays dropped — it is about to be dequeued, and
|
| 1142 |
+
// the message that follows is the copy the reader should have.
|
| 1143 |
+
if (out.messages.some((m) => m.superseded)) {
|
| 1144 |
+
out.messages = out.messages.filter((m) => !m.superseded);
|
| 1145 |
+
}
|
| 1146 |
+
for (const m of out.messages) delete m.superseded;
|
| 1147 |
}
|
| 1148 |
|
| 1149 |
// Codex — $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
|
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// A prompt typed while the agent is mid-turn.
|
| 2 |
+
//
|
| 3 |
+
// Claude Code does not write it as a `user` message. It writes
|
| 4 |
+
// {"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}
|
| 5 |
+
// and then either `dequeue` (the prompt arrives as an ordinary user message
|
| 6 |
+
// afterwards) or `remove` (it never does). Before this, `queue-operation`
|
| 7 |
+
// appeared nowhere in the repo, so every mid-turn prompt was invisible in the
|
| 8 |
+
// reader — permanently, not late. Two of the operator's own prompts were lost
|
| 9 |
+
// that way, which is the regression nobody would notice coming back.
|
| 10 |
+
//
|
| 11 |
+
// Run with: node test/queued-prompts.test.mjs
|
| 12 |
+
import assert from 'node:assert/strict';
|
| 13 |
+
import fs from 'node:fs';
|
| 14 |
+
import os from 'node:os';
|
| 15 |
+
import path from 'node:path';
|
| 16 |
+
import { readTraceByPath } from '../src/traces.js';
|
| 17 |
+
|
| 18 |
+
let failed = 0;
|
| 19 |
+
const check = (what, fn) => {
|
| 20 |
+
try { fn(); console.log(` ok ${what}`); } catch (e) {
|
| 21 |
+
failed++;
|
| 22 |
+
console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'am-queued-'));
|
| 26 |
+
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
| 27 |
+
const textOf = (m) => norm((m.blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join(' '));
|
| 28 |
+
|
| 29 |
+
let clock = Date.UTC(2026, 7, 20, 8, 0, 0);
|
| 30 |
+
const at = () => new Date((clock += 5000)).toISOString();
|
| 31 |
+
const user = (text) => ({ type: 'user', timestamp: at(), cwd: '/w', message: { role: 'user', content: [{ type: 'text', text }] } });
|
| 32 |
+
const assistant = (text) => ({ type: 'assistant', timestamp: at(), cwd: '/w', message: { id: `m${clock}`, role: 'assistant', model: 'claude-test', usage: { input_tokens: 10, output_tokens: 5 }, content: [{ type: 'text', text }] } });
|
| 33 |
+
const tool = (name) => ({ type: 'assistant', timestamp: at(), cwd: '/w', message: { id: `t${clock}`, role: 'assistant', model: 'claude-test', content: [{ type: 'tool_use', id: `u${clock}`, name, input: { file_path: '/w/x.js' } }] } });
|
| 34 |
+
const queue = (operation, content) => ({ type: 'queue-operation', operation, sessionId: 's', timestamp: at(), ...(content === undefined ? {} : { content }) });
|
| 35 |
+
|
| 36 |
+
const write = (name, records) => {
|
| 37 |
+
const f = path.join(dir, `${name}.jsonl`);
|
| 38 |
+
fs.writeFileSync(f, `${records.map((r) => JSON.stringify(r)).join('\n')}\n`);
|
| 39 |
+
return f;
|
| 40 |
+
};
|
| 41 |
+
const read = async (f) => {
|
| 42 |
+
const page = await readTraceByPath(f, { limit: 500 });
|
| 43 |
+
return (page.turns || []);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
// ---- the operator's case: queued, then removed, and never written as a message
|
| 47 |
+
{
|
| 48 |
+
const f = write('removed', [
|
| 49 |
+
user('start the nightly job'),
|
| 50 |
+
tool('Bash'),
|
| 51 |
+
queue('enqueue', 'ok, now API feedback. the user should be excluded by default'),
|
| 52 |
+
queue('remove', 'ok, now API feedback. the user should be excluded by default'),
|
| 53 |
+
assistant('Done — the job is running.'),
|
| 54 |
+
]);
|
| 55 |
+
const turns = await read(f);
|
| 56 |
+
const prompts = turns.filter((m) => m.role === 'user' && textOf(m));
|
| 57 |
+
check('a queued prompt that was removed is shown', () => {
|
| 58 |
+
assert.equal(prompts.length, 2, `expected the real prompt and the queued one, got ${prompts.map(textOf)}`);
|
| 59 |
+
assert.match(textOf(prompts[1]), /^ok, now API feedback/);
|
| 60 |
+
});
|
| 61 |
+
check('…and it says it was queued', () => assert.equal(prompts[1].queued, true));
|
| 62 |
+
check('…while an ordinary prompt does not', () => assert.equal(prompts[0].queued, undefined));
|
| 63 |
+
check('…and it keeps the time it was typed', () => assert.ok(prompts[1].ts > prompts[0].ts));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ---- the common case: queued, dequeued, and the real message arrives
|
| 67 |
+
{
|
| 68 |
+
const f = write('dequeued', [
|
| 69 |
+
user('start the nightly job'),
|
| 70 |
+
tool('Bash'),
|
| 71 |
+
queue('enqueue', 'and check the watcher after'),
|
| 72 |
+
queue('dequeue'),
|
| 73 |
+
user('and check the watcher after'),
|
| 74 |
+
assistant('Both done.'),
|
| 75 |
+
]);
|
| 76 |
+
const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
|
| 77 |
+
check('a dequeued prompt is shown once, by its real message', () => {
|
| 78 |
+
assert.equal(prompts.length, 2, `expected no duplicate, got ${prompts.map(textOf)}`);
|
| 79 |
+
assert.equal(textOf(prompts[1]), 'and check the watcher after');
|
| 80 |
+
assert.equal(prompts[1].queued, undefined, 'the real message should be the copy that survives');
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// ---- two queued at once, one taken and one removed: order and pairing hold
|
| 85 |
+
{
|
| 86 |
+
const f = write('both-paths', [
|
| 87 |
+
user('first'),
|
| 88 |
+
tool('Read'),
|
| 89 |
+
queue('enqueue', 'second, queued and taken'),
|
| 90 |
+
queue('enqueue', 'third, queued and removed'),
|
| 91 |
+
queue('dequeue'),
|
| 92 |
+
user('second, queued and taken'),
|
| 93 |
+
queue('remove', 'third, queued and removed'),
|
| 94 |
+
assistant('all three answered'),
|
| 95 |
+
]);
|
| 96 |
+
const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
|
| 97 |
+
check('the taken one comes from its message, the removed one from the queue', () => {
|
| 98 |
+
assert.deepEqual(prompts.map(textOf), ['first', 'third, queued and removed', 'second, queued and taken']);
|
| 99 |
+
assert.deepEqual(prompts.map((m) => !!m.queued), [false, true, false]);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// ---- the same words queued twice: FIFO, not "drop them all"
|
| 104 |
+
{
|
| 105 |
+
const f = write('twice', [
|
| 106 |
+
user('go'),
|
| 107 |
+
queue('enqueue', 'same words'),
|
| 108 |
+
queue('enqueue', 'same words'),
|
| 109 |
+
queue('dequeue'),
|
| 110 |
+
user('same words'),
|
| 111 |
+
queue('remove', 'same words'),
|
| 112 |
+
assistant('ok'),
|
| 113 |
+
]);
|
| 114 |
+
const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
|
| 115 |
+
check('one copy survives per prompt, not none and not four', () => {
|
| 116 |
+
assert.equal(prompts.filter((m) => textOf(m) === 'same words').length, 2);
|
| 117 |
+
assert.equal(prompts.filter((m) => m.queued).length, 1);
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// ---- harness noise must not become a prompt
|
| 122 |
+
{
|
| 123 |
+
const f = write('noise', [
|
| 124 |
+
user('go'),
|
| 125 |
+
queue('enqueue', '<task-notification>\n<task-id>abc</task-id>\n</task-notification>'),
|
| 126 |
+
queue('remove', '<task-notification>\n<task-id>abc</task-id>\n</task-notification>'),
|
| 127 |
+
queue('enqueue', '[Request interrupted by user]'),
|
| 128 |
+
queue('remove', '[Request interrupted by user]'),
|
| 129 |
+
assistant('ok'),
|
| 130 |
+
]);
|
| 131 |
+
const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
|
| 132 |
+
check('an enqueued task-notification is not shown as something the operator typed', () => {
|
| 133 |
+
assert.equal(prompts.length, 1, `only the real prompt should be a prompt, got ${prompts.map((m) => textOf(m).slice(0, 24))}`);
|
| 134 |
+
assert.equal(textOf(prompts[0]), 'go');
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// ---- still in the queue when the window ends: the message is coming, so wait
|
| 139 |
+
{
|
| 140 |
+
const f = write('pending', [
|
| 141 |
+
user('go'),
|
| 142 |
+
tool('Bash'),
|
| 143 |
+
queue('enqueue', 'typed while it works, not taken yet'),
|
| 144 |
+
]);
|
| 145 |
+
const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
|
| 146 |
+
check('a prompt still in the queue is left to its message rather than shown twice', () => {
|
| 147 |
+
assert.equal(prompts.length, 1);
|
| 148 |
+
assert.equal(textOf(prompts[0]), 'go');
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
fs.rmSync(dir, { recursive: true, force: true });
|
| 153 |
+
console.log(failed ? `\n${failed} failed` : '\nall checks passed');
|
| 154 |
+
process.exit(failed ? 1 : 0);
|
|
@@ -490,6 +490,14 @@ export type TraceBlock =
|
|
| 490 |
export interface TraceTurn {
|
| 491 |
role: 'user' | 'assistant' | 'system';
|
| 492 |
kind?: 'final' | 'update';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
ts?: number;
|
| 494 |
model?: string;
|
| 495 |
usage?: { in: number; out: number; cacheRead?: number };
|
|
|
|
| 490 |
export interface TraceTurn {
|
| 491 |
role: 'user' | 'assistant' | 'system';
|
| 492 |
kind?: 'final' | 'update';
|
| 493 |
+
/**
|
| 494 |
+
* A prompt typed while the agent was mid-turn, read from Claude Code's queue
|
| 495 |
+
* records because it is written nowhere else (server/src/traces.js). The
|
| 496 |
+
* reader says so on the band: the records cannot tell a prompt that was
|
| 497 |
+
* consumed from one that was cancelled, so it reports what it knows — that
|
| 498 |
+
* this was typed and queued — rather than claiming it was answered.
|
| 499 |
+
*/
|
| 500 |
+
queued?: boolean;
|
| 501 |
ts?: number;
|
| 502 |
model?: string;
|
| 503 |
usage?: { in: number; out: number; cacheRead?: number };
|
|
@@ -246,7 +246,15 @@ export function ExchangeView({
|
|
| 246 |
|
| 247 |
return (
|
| 248 |
<section className={`cx${dim ? ' dim' : ''}`}>
|
| 249 |
-
{prompt ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
{/* Everything about the turn on ONE line, under the prompt: what the work
|
| 252 |
was on the left, which turn it is on the right. Nothing above.
|
|
|
|
| 246 |
|
| 247 |
return (
|
| 248 |
<section className={`cx${dim ? ' dim' : ''}`}>
|
| 249 |
+
{prompt ? (
|
| 250 |
+
<div className="cx-prompt">
|
| 251 |
+
<Hi text={prompt} q={q} />
|
| 252 |
+
{/* Read from a queue record rather than a message: it was typed while
|
| 253 |
+
the agent was working. Labelled because those records cannot say
|
| 254 |
+
whether it was then consumed or cancelled — see TraceTurn.queued. */}
|
| 255 |
+
{x.prompt?.queued ? <span className="cx-queued mono" title="Typed while the agent was working, so it waited in the queue">queued</span> : null}
|
| 256 |
+
</div>
|
| 257 |
+
) : null}
|
| 258 |
|
| 259 |
{/* Everything about the turn on ONE line, under the prompt: what the work
|
| 260 |
was on the left, which turn it is on the right. Nothing above.
|
|
@@ -50,6 +50,11 @@
|
|
| 50 |
font-size: 1em; font-weight: 550; color: var(--text);
|
| 51 |
white-space: pre-wrap; overflow-wrap: break-word;
|
| 52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
.cx-prompt::before {
|
| 54 |
content: '❯'; position: absolute; left: 0.23em; top: 6px;
|
| 55 |
font-family: var(--font-mono); color: var(--accent); font-weight: 700;
|
|
|
|
| 50 |
font-size: 1em; font-weight: 550; color: var(--text);
|
| 51 |
white-space: pre-wrap; overflow-wrap: break-word;
|
| 52 |
}
|
| 53 |
+
/* the queue marker: a quiet aside on the band, not a second voice */
|
| 54 |
+
.cx-queued {
|
| 55 |
+
margin-left: 7px; padding: 0 4px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
| 56 |
+
font-size: 0.78em; font-weight: 500; color: var(--muted); vertical-align: 1px; white-space: nowrap;
|
| 57 |
+
}
|
| 58 |
.cx-prompt::before {
|
| 59 |
content: '❯'; position: absolute; left: 0.23em; top: 6px;
|
| 60 |
font-family: var(--font-mono); color: var(--accent); font-weight: 700;
|