Skip to content

Commit b6f4357

Browse files
authored
Merge pull request #291 from code-yeongyu/feat/thinking-block-durations
feat: per-thinking-block duration display in the interactive TUI
2 parents 5d75a53 + ce73ed5 commit b6f4357

21 files changed

Lines changed: 1139 additions & 107 deletions

File tree

.agents/skills/senpi-qa/references/mock-loop.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ take precedence for built-in providers and reach even the localhost fake.
6262
`--with-tool` scripts two turns (model → `bash` tool call → final text) to prove
6363
the full loop iterates. It passes `--approve` for project trust.
6464

65+
`--with-reasoning` scripts a thinking block before the final text in all three
66+
wire formats. The fake server records emitted deltas in a server-side stream log;
67+
the command fails unless the real CLI completes, returns
68+
`SENPI-QA-FINAL-MARKER-9d2c`, and that independent log reconstructs a reasoning
69+
body containing `SENPI-QA-REASONING-MARKER-7f3a`. `--slow` makes the reasoning
70+
body long, emits it in 12 chunks, and uses a 500ms inter-chunk delay so an abort
71+
or steering driver has a deterministic in-flight window.
72+
73+
```bash
74+
node .agents/skills/senpi-qa/scripts/mock-loop.mjs \
75+
--with-reasoning --api openai-responses
76+
```
77+
78+
`--with-reasoning --serve --serve-env /tmp/senpi-qa.env` keeps that same
79+
reasoning-scripted server alive for an external TUI. Once the sandbox, localhost
80+
server, and `models.json` are ready, it prints `SENPI_QA_SERVE_READY=1`; the
81+
env file is sourceable and contains the isolated agent/session paths,
82+
`SENPI_QA_MODELS_JSON`, and explicitly blanked provider-key variables so ambient
83+
credentials cannot leak into the TUI. It prints an auth-guard receipt before the
84+
ready sentinel and shuts down the local server on `SIGTERM`.
85+
6586
`--with-text-tool-leak` and `--with-truncated-text-tool-leak` exercise Claude
6687
XML recovery through the real source CLI for `anthropic-messages` and
6788
`openai-completions`:

.agents/skills/senpi-qa/scripts/lib/fake-model-server.mjs

Lines changed: 166 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* - `/messages` -> Anthropic Messages (api "anthropic-messages")
99
* - `/responses` -> OpenAI Responses (api "openai-responses")
1010
*
11-
* Turns are scripted protocol-independently ({ text?, toolCalls? }); each
12-
* handler renders the matching SSE. Run `node fake-model-server.mjs --self-test`.
11+
* Turns are scripted protocol-independently ({ reasoning?, text?, toolCalls? });
12+
* each handler renders the matching SSE. Run `node fake-model-server.mjs --self-test`.
1313
*/
1414

1515
import { createServer } from "node:http";
@@ -18,16 +18,17 @@ import { pathToFileURL } from "node:url";
1818
const isMain = import.meta.url === pathToFileURL(process.argv[1] || "").href;
1919

2020
/**
21-
* @param {{ port?: number, turns?: Array<{text?:string, chunks?:number, chunkDelayMs?:number, toolCalls?:Array<{id?:string,name:string,args:object}>}> }} opts
22-
* @returns {Promise<{url:string, origin:string, port:number, requests:object[], stop:()=>Promise<void>}>}
21+
* @param {{ port?: number, turns?: Array<{reasoning?:string, text?:string, chunks?:number, chunkDelayMs?:number, toolCalls?:Array<{id?:string,name:string,args:object}>}> }} opts
22+
* @returns {Promise<{url:string, origin:string, port:number, requests:object[], streamLog:Array<{streamId:number,protocol:string,kind:string,delta:string}>, stop:()=>Promise<void>}>}
2323
*
24-
* A turn's `text` is emitted as ONE delta by default. Set `chunks` (>1) to split
25-
* it into that many text deltas so streaming has an in-flight window (abort/steer
26-
* QA); `chunkDelayMs` spaces those deltas apart. Both absent = byte-identical
27-
* single-delta behavior.
24+
* A turn's non-empty `reasoning` and `text` fields are each emitted as ONE delta
25+
* by default. Set `chunks` (>1) to split either field into that many deltas and
26+
* `chunkDelayMs` to space those deltas apart. One shared emitter keeps text and
27+
* reasoning on the identical chunking/delay path for abort and steering QA.
2828
*/
2929
export function startFakeModelServer({ port = 0, turns = [{ text: "OK" }] } = {}) {
3030
const requests = [];
31+
const streamLog = [];
3132
let callIndex = 0;
3233

3334
const server = createServer((req, res) => {
@@ -62,13 +63,14 @@ export function startFakeModelServer({ port = 0, turns = [{ text: "OK" }] } = {}
6263
return sendJson(res, 200, { object: "list", data: [{ id: body.model || "mock", object: "model" }] });
6364
}
6465

66+
const streamId = callIndex;
6567
const turn = turns[Math.min(callIndex, turns.length - 1)] || { text: "OK" };
6668
callIndex++;
6769
const modelId = body.model || "mock";
6870

69-
if (url.includes("/chat/completions")) return writeCompletionsSse(res, turn, modelId);
70-
if (url.includes("/messages")) return writeAnthropicSse(res, turn, modelId);
71-
if (url.includes("/responses")) return writeResponsesSse(res, turn, modelId);
71+
if (url.includes("/chat/completions")) return writeCompletionsSse(res, turn, modelId, streamLog, streamId);
72+
if (url.includes("/messages")) return writeAnthropicSse(res, turn, modelId, streamLog, streamId);
73+
if (url.includes("/responses")) return writeResponsesSse(res, turn, modelId, streamLog, streamId);
7274
return sendJson(res, 404, { error: { message: `no route: ${req.method} ${url}` } });
7375
});
7476
});
@@ -83,6 +85,7 @@ export function startFakeModelServer({ port = 0, turns = [{ text: "OK" }] } = {}
8385
origin,
8486
port: actual,
8587
requests,
88+
streamLog,
8689
stop: () => new Promise((r) => server.close(() => r())),
8790
});
8891
});
@@ -117,19 +120,18 @@ function splitIntoChunks(text, n) {
117120
}
118121

119122
/**
120-
* Emit a turn's text via `emitDelta`, then run `done`. Without `chunks` this is a
121-
* single synchronous `emitDelta(turn.text)` (byte-identical to legacy behavior);
122-
* with `chunks` > 1 the text is split into that many deltas written `chunkDelayMs`
123-
* apart, and `done` runs after the last one so the response closes in order.
123+
* Emit either scripted string field through the shared chunk/delay machinery,
124+
* then run `done`. Without `chunks` each non-empty field is one synchronous
125+
* delta; with `chunks` > 1 it is split and spaced by `chunkDelayMs`.
124126
*/
125-
function emitTextDeltas(turn, emitDelta, done) {
126-
if (!turn.text) return done();
127+
function emitDeltas(turn, value, emitDelta, done) {
128+
if (!value) return done();
127129
const n = Number.isInteger(turn.chunks) && turn.chunks > 1 ? turn.chunks : 0;
128130
if (!n) {
129-
emitDelta(turn.text);
131+
emitDelta(value);
130132
return done();
131133
}
132-
const pieces = splitIntoChunks(turn.text, n);
134+
const pieces = splitIntoChunks(value, n);
133135
const delay = Number.isFinite(turn.chunkDelayMs) ? turn.chunkDelayMs : 0;
134136
let i = 0;
135137
const tick = () => {
@@ -141,8 +143,12 @@ function emitTextDeltas(turn, emitDelta, done) {
141143
tick();
142144
}
143145

146+
function recordDelta(streamLog, streamId, protocol, kind, delta) {
147+
streamLog.push({ streamId, protocol, kind, delta });
148+
}
149+
144150
// --- OpenAI chat completions ---------------------------------------------
145-
function writeCompletionsSse(res, turn, modelId) {
151+
function writeCompletionsSse(res, turn, modelId, streamLog, streamId) {
146152
sseHead(res);
147153
const base = { id: "chatcmpl-mock", object: "chat.completion.chunk", created: 0, model: modelId };
148154
const send = (delta, finish = null) =>
@@ -153,18 +159,36 @@ function writeCompletionsSse(res, turn, modelId) {
153159
type: "function",
154160
function: { name: tc.name, arguments: JSON.stringify(tc.args ?? {}) },
155161
}));
156-
send({ role: "assistant", content: "" });
157-
emitTextDeltas(turn, (t) => send({ content: t }), () => {
162+
const finish = () => {
158163
if (tcs.length) send({ tool_calls: tcs });
159164
send({}, tcs.length ? "tool_calls" : "stop");
160165
res.write(`data: ${JSON.stringify({ ...base, choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`);
161166
res.write("data: [DONE]\n\n");
162167
res.end();
163-
});
168+
};
169+
send({ role: "assistant", content: "" });
170+
emitDeltas(
171+
turn,
172+
turn.reasoning,
173+
(delta) => {
174+
send({ reasoning_content: delta });
175+
recordDelta(streamLog, streamId, "openai-completions", "reasoning_delta", delta);
176+
},
177+
() =>
178+
emitDeltas(
179+
turn,
180+
turn.text,
181+
(delta) => {
182+
send({ content: delta });
183+
recordDelta(streamLog, streamId, "openai-completions", "text_delta", delta);
184+
},
185+
finish,
186+
),
187+
);
164188
}
165189

166190
// --- Anthropic Messages ---------------------------------------------------
167-
function writeAnthropicSse(res, turn, modelId) {
191+
function writeAnthropicSse(res, turn, modelId, streamLog, streamId) {
168192
sseHead(res);
169193
const ev = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`);
170194
ev("message_start", {
@@ -183,20 +207,42 @@ function writeAnthropicSse(res, turn, modelId) {
183207
ev("message_stop", {});
184208
res.end();
185209
};
186-
if (turn.text) {
210+
const emitText = () => {
211+
if (!turn.text) return afterText();
187212
ev("content_block_start", { index, content_block: { type: "text", text: "" } });
188-
emitTextDeltas(turn, (t) => ev("content_block_delta", { index, delta: { type: "text_delta", text: t } }), () => {
213+
emitDeltas(
214+
turn,
215+
turn.text,
216+
(delta) => {
217+
ev("content_block_delta", { index, delta: { type: "text_delta", text: delta } });
218+
recordDelta(streamLog, streamId, "anthropic-messages", "text_delta", delta);
219+
},
220+
() => {
221+
ev("content_block_stop", { index });
222+
index++;
223+
afterText();
224+
},
225+
);
226+
};
227+
if (!turn.reasoning) return emitText();
228+
ev("content_block_start", { index, content_block: { type: "thinking", thinking: "" } });
229+
emitDeltas(
230+
turn,
231+
turn.reasoning,
232+
(delta) => {
233+
ev("content_block_delta", { index, delta: { type: "thinking_delta", thinking: delta } });
234+
recordDelta(streamLog, streamId, "anthropic-messages", "reasoning_delta", delta);
235+
},
236+
() => {
189237
ev("content_block_stop", { index });
190238
index++;
191-
afterText();
192-
});
193-
} else {
194-
afterText();
195-
}
239+
emitText();
240+
},
241+
);
196242
}
197243

198244
// --- OpenAI Responses -----------------------------------------------------
199-
function writeResponsesSse(res, turn, modelId) {
245+
function writeResponsesSse(res, turn, modelId, streamLog, streamId) {
200246
sseHead(res);
201247
let seq = 0;
202248
const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...data })}\n\n`);
@@ -211,20 +257,48 @@ function writeResponsesSse(res, turn, modelId) {
211257
});
212258
res.end();
213259
};
214-
if (turn.text) {
260+
const emitText = () => {
261+
if (!turn.text) return afterText();
215262
const itemId = "msg_mock";
216263
ev("response.output_item.added", { output_index: outputIndex, item: { id: itemId, type: "message", status: "in_progress", role: "assistant", content: [] } });
217264
ev("response.content_part.added", { item_id: itemId, output_index: outputIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } });
218-
emitTextDeltas(turn, (t) => ev("response.output_text.delta", { item_id: itemId, output_index: outputIndex, content_index: 0, delta: t }), () => {
219-
const item = { id: itemId, type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: turn.text, annotations: [] }] };
220-
ev("response.output_item.done", { output_index: outputIndex, item });
221-
outputItems.push(item);
222-
outputIndex++;
223-
afterText();
224-
});
225-
} else {
226-
afterText();
227-
}
265+
emitDeltas(
266+
turn,
267+
turn.text,
268+
(delta) => {
269+
ev("response.output_text.delta", { item_id: itemId, output_index: outputIndex, content_index: 0, delta });
270+
recordDelta(streamLog, streamId, "openai-responses", "text_delta", delta);
271+
},
272+
() => {
273+
const item = { id: itemId, type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: turn.text, annotations: [] }] };
274+
ev("response.output_item.done", { output_index: outputIndex, item });
275+
outputItems.push(item);
276+
outputIndex++;
277+
afterText();
278+
},
279+
);
280+
};
281+
const emitReasoning = () => {
282+
if (!turn.reasoning) return emitText();
283+
const itemId = "rsn_mock";
284+
ev("response.output_item.added", { output_index: outputIndex, item: { id: itemId, type: "reasoning", status: "in_progress", summary: [] } });
285+
emitDeltas(
286+
turn,
287+
turn.reasoning,
288+
(delta) => {
289+
ev("response.reasoning_summary_text.delta", { item_id: itemId, output_index: outputIndex, summary_index: 0, delta });
290+
recordDelta(streamLog, streamId, "openai-responses", "reasoning_delta", delta);
291+
},
292+
() => {
293+
const item = { id: itemId, type: "reasoning", status: "completed", summary: [{ type: "summary_text", text: turn.reasoning }] };
294+
ev("response.output_item.done", { output_index: outputIndex, item });
295+
outputItems.push(item);
296+
outputIndex++;
297+
emitText();
298+
},
299+
);
300+
};
301+
emitReasoning();
228302

229303
function emitToolCalls() {
230304
for (const tc of turn.toolCalls || []) {
@@ -241,25 +315,65 @@ function writeResponsesSse(res, turn, modelId) {
241315
}
242316
}
243317

318+
function byteSequenceInOrder(bytes, parts) {
319+
let offset = 0;
320+
for (const part of parts) {
321+
const index = bytes.indexOf(Buffer.from(part), offset);
322+
if (index < 0) return false;
323+
offset = index + Buffer.byteLength(part);
324+
}
325+
return true;
326+
}
327+
244328
// --- self-test ------------------------------------------------------------
245329
async function selfTest() {
246-
const srv = await startFakeModelServer({ turns: [{ text: "FAKE-OK" }] });
330+
const reasoning = "FAKE-THINK";
331+
const text = "FAKE-OK";
332+
const reasoningPieces = splitIntoChunks(reasoning, 2);
333+
const textPieces = splitIntoChunks(text, 2);
334+
const srv = await startFakeModelServer({ turns: [{ reasoning, text, chunks: 2, chunkDelayMs: 0 }] });
247335
const checks = [];
248-
const probe = async (label, path, headers) => {
336+
const probe = async (label, path, headers, expectedBytes) => {
249337
const r = await fetch(`${srv.origin}${path}`, {
250338
method: "POST",
251339
headers: { "content-type": "application/json", ...headers },
252340
body: JSON.stringify({ model: "m", stream: true, messages: [{ role: "user", content: "hi" }] }),
253341
});
254-
const text = await r.text();
255-
const ok = text.includes("FAKE-OK");
256-
checks.push(ok);
257-
process.stdout.write(`[${ok ? "PASS" : "FAIL"}] ${label} streamed scripted text\n`);
342+
const bytes = Buffer.from(await r.arrayBuffer());
343+
const scriptedText = textPieces.every((piece) => bytes.includes(Buffer.from(piece)));
344+
const exactReasoningSequence = byteSequenceInOrder(bytes, expectedBytes);
345+
checks.push(scriptedText, exactReasoningSequence);
346+
process.stdout.write(`[${scriptedText ? "PASS" : "FAIL"}] ${label} streamed scripted text\n`);
347+
process.stdout.write(`[${exactReasoningSequence ? "PASS" : "FAIL"}] ${label} reasoning SSE bytes are ordered and complete\n`);
258348
};
259349
try {
260-
await probe("openai-completions", "/v1/chat/completions", { authorization: "Bearer k" });
261-
await probe("anthropic-messages", "/v1/messages", { "x-api-key": "k" });
262-
await probe("openai-responses", "/v1/responses", { authorization: "Bearer k" });
350+
await probe("openai-completions", "/v1/chat/completions", { authorization: "Bearer k" }, [
351+
`"reasoning_content":"${reasoningPieces[0]}"`,
352+
`"reasoning_content":"${reasoningPieces[1]}"`,
353+
`"content":"${textPieces[0]}"`,
354+
`"content":"${textPieces[1]}"`,
355+
"data: [DONE]\n\n",
356+
]);
357+
await probe("anthropic-messages", "/v1/messages", { "x-api-key": "k" }, [
358+
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
359+
`event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"${reasoningPieces[0]}"}}\n\n`,
360+
`event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"${reasoningPieces[1]}"}}\n\n`,
361+
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
362+
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
363+
`event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"${textPieces[0]}"}}\n\n`,
364+
]);
365+
await probe("openai-responses", "/v1/responses", { authorization: "Bearer k" }, [
366+
"event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rsn_mock\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"summary\":[]}}\n\n",
367+
`event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":2,"item_id":"rsn_mock","output_index":0,"summary_index":0,"delta":"${reasoningPieces[0]}"}\n\n`,
368+
`event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":3,"item_id":"rsn_mock","output_index":0,"summary_index":0,"delta":"${reasoningPieces[1]}"}\n\n`,
369+
"event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":4,\"output_index\":0,\"item\":{\"id\":\"rsn_mock\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"FAKE-THINK\"}]}}\n\n",
370+
"event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":5,\"output_index\":1,\"item\":{\"id\":\"msg_mock\",\"type\":\"message\"",
371+
"event: response.completed\n",
372+
]);
373+
const loggedReasoning = srv.streamLog.filter((entry) => entry.kind === "reasoning_delta").map((entry) => entry.delta);
374+
const logComplete = loggedReasoning.join("") === reasoning.repeat(3);
375+
checks.push(logComplete);
376+
process.stdout.write(`[${logComplete ? "PASS" : "FAIL"}] server stream log recorded all reasoning chunks\n`);
263377
} finally {
264378
await srv.stop();
265379
}

.agents/skills/senpi-qa/scripts/lib/mock-loop-cli.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const KNOWN_COMMANDS = new Set([
22
"--run",
33
"--self-test",
44
"--with-mcp-tool",
5+
"--with-reasoning",
56
"--with-text-tool-leak",
67
"--with-tool",
78
"--with-truncated-text-tool-leak",

.agents/skills/senpi-qa/scripts/lib/mock-loop-support.mjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,22 @@ export const API_PRESETS = {
3232

3333
export const ALL_APIS = Object.keys(API_PRESETS);
3434

35-
const PROVIDER_ENV_KEYS = [
35+
export const QA_REASONING_MARKER = "SENPI-QA-REASONING-MARKER-7f3a";
36+
export const QA_FINAL_MARKER = "SENPI-QA-FINAL-MARKER-9d2c";
37+
38+
/** Shared reasoning-first mock turn for loop, RPC, and external TUI QA. */
39+
export function reasoningScriptedTurn({ slow = false } = {}) {
40+
return {
41+
reasoning: slow
42+
? `${QA_REASONING_MARKER} ${"Keep streaming deterministic reasoning for an abort window. ".repeat(36)}`
43+
: `${QA_REASONING_MARKER} deterministic mock reasoning before the final answer.`,
44+
text: QA_FINAL_MARKER,
45+
chunks: slow ? 12 : 3,
46+
chunkDelayMs: slow ? 500 : 0,
47+
};
48+
}
49+
50+
export const PROVIDER_ENV_KEYS = [
3651
"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "DEEPSEEK_API_KEY",
3752
"NVIDIA_API_KEY", "GEMINI_API_KEY", "GOOGLE_CLOUD_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY", "XAI_API_KEY",
3853
"FIREWORKS_API_KEY", "TOGETHER_API_KEY", "OPENROUTER_API_KEY", "AI_GATEWAY_API_KEY", "ZAI_API_KEY",

0 commit comments

Comments
 (0)