mixdog 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/atomic-lock-tryonce-test.mjs +66 -0
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/src/mixdog-session-runtime.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
- package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
- package/src/runtime/channels/backends/discord.mjs +97 -7
- package/src/runtime/channels/index.mjs +150 -23
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
- package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/index.mjs +37 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -0
- package/src/runtime/shared/atomic-file.mjs +110 -0
- package/src/runtime/shared/transcript-writer.mjs +46 -4
- package/src/session-runtime/provider-models.mjs +47 -8
- package/src/standalone/channel-worker.mjs +14 -2
- package/src/tui/app/transcript-window.mjs +137 -6
- package/src/tui/app/use-transcript-window.mjs +67 -10
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/dist/index.mjs +436 -93
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +66 -14
- package/src/tui/index.jsx +97 -6
- package/src/ui/statusline-segments.mjs +54 -36
- package/src/ui/statusline.mjs +141 -95
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"smoke:freevars": "node scripts/freevar-smoke.mjs",
|
|
47
47
|
"test:toolcall": "node --test scripts/toolcall-args-test.mjs",
|
|
48
48
|
"test:providers": "node --test scripts/provider-toolcall-test.mjs",
|
|
49
|
+
"test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
|
|
49
50
|
"failures": "node scripts/tool-failures.mjs",
|
|
50
51
|
"trace:llm": "node scripts/llm-trace-summary.mjs",
|
|
51
52
|
"diag:sessions": "node scripts/session-diag.mjs",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Proves try-once (timeoutMs:0) lock behavior: when the lock is already held,
|
|
2
|
+
// withFileLockSync/withFileLock return IMMEDIATELY with ELOCKCONTENDED and
|
|
3
|
+
// never sleep (no Atomics.wait / setTimeout backoff). Also asserts sync+async
|
|
4
|
+
// lock interop: neither can enter the critical section while the other holds.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
withFileLockSync,
|
|
12
|
+
withFileLock,
|
|
13
|
+
} from '../src/runtime/shared/atomic-file.mjs';
|
|
14
|
+
|
|
15
|
+
function tmpLock() {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixlock-'));
|
|
17
|
+
return { dir, lockPath: join(dir, 't.lock') };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('try-once sync throws ELOCKCONTENDED without sleeping when held', () => {
|
|
21
|
+
const { dir, lockPath } = tmpLock();
|
|
22
|
+
try {
|
|
23
|
+
withFileLockSync(lockPath, () => {
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => withFileLockSync(lockPath, () => 'unreachable', { timeoutMs: 0 }),
|
|
27
|
+
(e) => e?.code === 'ELOCKCONTENDED',
|
|
28
|
+
);
|
|
29
|
+
assert.ok(Date.now() - started < 20, `try-once slept ${Date.now() - started}ms`);
|
|
30
|
+
});
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('try-once async rejects ELOCKCONTENDED without sleeping when held', async () => {
|
|
37
|
+
const { dir, lockPath } = tmpLock();
|
|
38
|
+
try {
|
|
39
|
+
await withFileLockSync(lockPath, async () => {
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
await assert.rejects(
|
|
42
|
+
withFileLock(lockPath, () => 'unreachable', { timeoutMs: 0 }),
|
|
43
|
+
(e) => e?.code === 'ELOCKCONTENDED',
|
|
44
|
+
);
|
|
45
|
+
assert.ok(Date.now() - started < 20, `try-once slept ${Date.now() - started}ms`);
|
|
46
|
+
});
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('async holder blocks sync try-once, then sync acquires after release', async () => {
|
|
53
|
+
const { dir, lockPath } = tmpLock();
|
|
54
|
+
try {
|
|
55
|
+
await withFileLock(lockPath, () => {
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => withFileLockSync(lockPath, () => 'unreachable', { timeoutMs: 0 }),
|
|
58
|
+
(e) => e?.code === 'ELOCKCONTENDED',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
const val = withFileLockSync(lockPath, () => 7, { timeoutMs: 0 });
|
|
62
|
+
assert.equal(val, 7);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
@@ -366,6 +366,81 @@ test('anthropic(-oauth): text-only stream → no toolCalls', async () => {
|
|
|
366
366
|
assert.equal(result.content, 'hello');
|
|
367
367
|
});
|
|
368
368
|
|
|
369
|
+
test('anthropic(-oauth): thinking + signature deltas → ordered thinkingBlocks before tool_use', async () => {
|
|
370
|
+
const events = [
|
|
371
|
+
{ type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
|
|
372
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
|
|
373
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'step ' } },
|
|
374
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'one' } },
|
|
375
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'sig123' } },
|
|
376
|
+
{ type: 'content_block_stop', index: 0 },
|
|
377
|
+
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_9', name: 'shell' } },
|
|
378
|
+
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"command":"ls"}' } },
|
|
379
|
+
{ type: 'content_block_stop', index: 1 },
|
|
380
|
+
{ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 1 } },
|
|
381
|
+
{ type: 'message_stop' },
|
|
382
|
+
];
|
|
383
|
+
const result = await anthropicParseSSEStream(
|
|
384
|
+
anthropicSseResponse(events),
|
|
385
|
+
null, () => {}, () => {}, () => {}, {}, null,
|
|
386
|
+
);
|
|
387
|
+
assert.equal(result.hasThinkingContent, true);
|
|
388
|
+
assert.deepEqual(result.thinkingBlocks, [
|
|
389
|
+
{ type: 'thinking', thinking: 'step one', signature: 'sig123' },
|
|
390
|
+
]);
|
|
391
|
+
assert.deepEqual(result.toolCalls, [{ id: 'toolu_9', name: 'shell', arguments: { command: 'ls' } }]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('anthropic(-oauth): signature-only block (display omitted) → empty thinking kept with signature', async () => {
|
|
395
|
+
const events = [
|
|
396
|
+
{ type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
|
|
397
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking' } },
|
|
398
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'sigABC' } },
|
|
399
|
+
{ type: 'content_block_stop', index: 0 },
|
|
400
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
|
|
401
|
+
{ type: 'message_stop' },
|
|
402
|
+
];
|
|
403
|
+
const result = await anthropicParseSSEStream(
|
|
404
|
+
anthropicSseResponse(events),
|
|
405
|
+
null, () => {}, () => {}, () => {}, {}, null,
|
|
406
|
+
);
|
|
407
|
+
assert.deepEqual(result.thinkingBlocks, [
|
|
408
|
+
{ type: 'thinking', thinking: '', signature: 'sigABC' },
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('anthropic(-oauth): redacted_thinking round-trips exactly as {type,data} (no extra fields)', async () => {
|
|
413
|
+
const events = [
|
|
414
|
+
{ type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
|
|
415
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'redacted_thinking', data: 'ENCRYPTED_PAYLOAD' } },
|
|
416
|
+
{ type: 'content_block_stop', index: 0 },
|
|
417
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
|
|
418
|
+
{ type: 'message_stop' },
|
|
419
|
+
];
|
|
420
|
+
const result = await anthropicParseSSEStream(
|
|
421
|
+
anthropicSseResponse(events),
|
|
422
|
+
null, () => {}, () => {}, () => {}, {}, null,
|
|
423
|
+
);
|
|
424
|
+
assert.deepEqual(result.thinkingBlocks, [
|
|
425
|
+
{ type: 'redacted_thinking', data: 'ENCRYPTED_PAYLOAD' },
|
|
426
|
+
]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('anthropic effort: legacy claude-3-7-sonnet gets NO adaptive thinking / effort beta', () => {
|
|
430
|
+
const model = 'claude-3-7-sonnet-20250219';
|
|
431
|
+
assert.equal(modelSupportsEffort(model), false);
|
|
432
|
+
const body = _buildRequestBodyForCacheSmoke(
|
|
433
|
+
[{ role: 'user', content: 'hi' }],
|
|
434
|
+
model,
|
|
435
|
+
[],
|
|
436
|
+
{ effort: 'high' },
|
|
437
|
+
);
|
|
438
|
+
assert.equal(body.output_config, undefined);
|
|
439
|
+
// Legacy path uses the budget_tokens shape, never thinking:adaptive.
|
|
440
|
+
assert.notEqual(body.thinking?.type, 'adaptive');
|
|
441
|
+
assert.equal(shouldIncludeEffortBeta(model, { effort: 'high' }), false);
|
|
442
|
+
});
|
|
443
|
+
|
|
369
444
|
// --- Leaked tool-call recovery (shared parseSSEStream guard) ----------------
|
|
370
445
|
// The model sometimes emits a tool call as plain text tags inside text_delta
|
|
371
446
|
// instead of a native tool_use block. The guard (8th arg = known tool names)
|
|
@@ -933,7 +1008,9 @@ test('anthropic effort: sonnet-4-6 uses output_config + effort beta, not thinkin
|
|
|
933
1008
|
{ effort: 'high' },
|
|
934
1009
|
);
|
|
935
1010
|
assert.deepEqual(body.output_config, { effort: 'high' });
|
|
936
|
-
|
|
1011
|
+
// Adaptive-thinking models also carry thinking:{type:'adaptive'} — the
|
|
1012
|
+
// legacy budget_tokens shape 400s on these models.
|
|
1013
|
+
assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' });
|
|
937
1014
|
assert.equal(shouldIncludeEffortBeta(model, { effort: 'high' }), true);
|
|
938
1015
|
const beta = buildAnthropicBetaHeaders({ effort: true });
|
|
939
1016
|
assert.ok(beta.includes(EFFORT_BETA_HEADER));
|
|
@@ -967,7 +1044,7 @@ test('anthropic effort: xhigh on opus-4-8 is a first-class level (not downgraded
|
|
|
967
1044
|
{ effort: 'xhigh' },
|
|
968
1045
|
);
|
|
969
1046
|
assert.deepEqual(body.output_config, { effort: 'xhigh' });
|
|
970
|
-
assert.
|
|
1047
|
+
assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' });
|
|
971
1048
|
});
|
|
972
1049
|
|
|
973
1050
|
test('anthropic effort: explicit thinkingBudgetTokens wins over effort', () => {
|
|
@@ -1553,6 +1553,12 @@ export async function createMixdogSessionRuntime({
|
|
|
1553
1553
|
// caller: the whole probe is detached, but it internally awaits readiness.
|
|
1554
1554
|
void (async () => {
|
|
1555
1555
|
try {
|
|
1556
|
+
// Yield one event-loop tick before the heavy chain below (module
|
|
1557
|
+
// resolve, daemon fork/health-poll) starts, so Ink's next render
|
|
1558
|
+
// (scheduled via setImmediate/timers) and any queued keypress
|
|
1559
|
+
// events get a turn first instead of being starved by this
|
|
1560
|
+
// detached chain's synchronous setup work.
|
|
1561
|
+
await new Promise((r) => setImmediate(r));
|
|
1556
1562
|
const mod = await getMemoryModule();
|
|
1557
1563
|
const started = typeof mod?.start === 'function' ? await mod.start() : null;
|
|
1558
1564
|
const port = started?.port;
|
|
@@ -1603,6 +1609,12 @@ export async function createMixdogSessionRuntime({
|
|
|
1603
1609
|
}
|
|
1604
1610
|
bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
|
|
1605
1611
|
void (async () => {
|
|
1612
|
+
// Yield before the createCurrentSession/transcript/fork chain below —
|
|
1613
|
+
// same rationale as the memory-eager-init yield above: this detached
|
|
1614
|
+
// chain runs synchronous config/fs work (createCurrentSession, backend
|
|
1615
|
+
// flush, transcript writer) back-to-back, and without a tick break it
|
|
1616
|
+
// can run ahead of Ink's queued render/input handling.
|
|
1617
|
+
await new Promise((r) => setImmediate(r));
|
|
1606
1618
|
// Immediate-occupancy guarantee: make sure a session + transcript
|
|
1607
1619
|
// exist BEFORE the worker boots — a freshly-forked worker claims and
|
|
1608
1620
|
// runs transcript discovery inside its own start(), so publishing the
|
|
@@ -43,6 +43,15 @@ function parseClaudeVersion(model) {
|
|
|
43
43
|
if (pair) {
|
|
44
44
|
return { family: pair[1], major: Number(pair[2]), minor: null };
|
|
45
45
|
}
|
|
46
|
+
// Legacy naming: version comes BEFORE the family (claude-3-7-sonnet,
|
|
47
|
+
// claude-3-5-sonnet, claude-3-opus). These are manual-thinking models —
|
|
48
|
+
// parse them so isLegacyAnthropicReasoningModel excludes them instead of
|
|
49
|
+
// letting modelSupportsEffort's `startsWith('claude-')` fallthrough grant
|
|
50
|
+
// them adaptive thinking + the effort beta (a hard 400).
|
|
51
|
+
const legacy = m.match(/^claude-(\d+)-(\d+)-(haiku|sonnet|opus)/);
|
|
52
|
+
if (legacy) {
|
|
53
|
+
return { family: legacy[3], major: Number(legacy[1]), minor: Number(legacy[2]) };
|
|
54
|
+
}
|
|
46
55
|
return null;
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -69,7 +78,17 @@ export function modelSupportsEffort(model) {
|
|
|
69
78
|
if (m.includes('sonnet-5') || m.includes('fable-5')) return true;
|
|
70
79
|
if (/^claude-opus-4-(6|7|8)(?:$|[-@])/.test(m)) return true;
|
|
71
80
|
if (/^claude-opus-5-/.test(m)) return true;
|
|
72
|
-
|
|
81
|
+
// Fallthrough for not-yet-enumerated modern models: only grant effort when
|
|
82
|
+
// parseClaudeVersion resolves a family with major>=4 (and not a manual-
|
|
83
|
+
// thinking legacy already excluded above). A bare `startsWith('claude-')`
|
|
84
|
+
// here would hand thinking:adaptive + the effort beta to legacy/unknown
|
|
85
|
+
// IDs (e.g. claude-3-7-sonnet) → hard 400. Unknown-shape IDs (no version
|
|
86
|
+
// parsed) fall through to false: no effort, no adaptive thinking.
|
|
87
|
+
const parsed = parseClaudeVersion(model);
|
|
88
|
+
if (parsed && (parsed.family === 'sonnet' || parsed.family === 'opus' || parsed.family === 'fable')
|
|
89
|
+
&& parsed.major >= 4) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
73
92
|
return false;
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -180,6 +199,19 @@ export function applyAnthropicEffortToBody(
|
|
|
180
199
|
: {};
|
|
181
200
|
body.output_config = { ...existing, effort: normalized };
|
|
182
201
|
}
|
|
202
|
+
// Adaptive-thinking models (4.6+) require `thinking:{type:"adaptive"}`
|
|
203
|
+
// rather than the legacy budget_tokens shape — sending
|
|
204
|
+
// `thinking:{type:"enabled"}` here 400s on sonnet-5/opus-4-7/4-8.
|
|
205
|
+
// display:"summarized" keeps reasoning blocks populated (4.7+ defaults
|
|
206
|
+
// to "omitted", silently hiding reasoning text). Gated on the same
|
|
207
|
+
// modelSupportsEffort() allowlist so older models never receive it.
|
|
208
|
+
// Set unconditionally (independent of `normalized`) so effort-capable
|
|
209
|
+
// turns always carry adaptive thinking + round-trip signatures.
|
|
210
|
+
body.thinking = { type: 'adaptive', display: 'summarized' };
|
|
211
|
+
// Adaptive/4.7+ models reject any non-default sampling param with a 400.
|
|
212
|
+
delete body.temperature;
|
|
213
|
+
delete body.top_p;
|
|
214
|
+
delete body.top_k;
|
|
183
215
|
return;
|
|
184
216
|
}
|
|
185
217
|
|
|
@@ -333,6 +333,15 @@ function toAnthropicMessages(messages) {
|
|
|
333
333
|
content = m.assistantBlocks.slice();
|
|
334
334
|
} else {
|
|
335
335
|
content = [];
|
|
336
|
+
// Adaptive-thinking round-trip: prior-turn thinking blocks are
|
|
337
|
+
// REQUIRED back, unmodified (signature intact; empty thinking
|
|
338
|
+
// field allowed), and MUST precede tool_use blocks. Emit them
|
|
339
|
+
// first, verbatim as received from the SSE parser.
|
|
340
|
+
if (Array.isArray(m.thinkingBlocks)) {
|
|
341
|
+
for (const tb of m.thinkingBlocks) {
|
|
342
|
+
if (tb && typeof tb === 'object') content.push(tb);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
336
345
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
337
346
|
for (const tc of m.toolCalls) {
|
|
338
347
|
content.push({
|
|
@@ -102,6 +102,11 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
102
102
|
let content = '';
|
|
103
103
|
let hasThinkingContent = false;
|
|
104
104
|
const contentBlockTypes = new Set();
|
|
105
|
+
// Ordered extended-thinking blocks, keyed by content_block index. Each
|
|
106
|
+
// holds the accumulated thinking text + signature exactly as received so
|
|
107
|
+
// it can be round-tripped verbatim on tool-continuation turns (required
|
|
108
|
+
// back on tool_use turns; empty thinking + signature is valid).
|
|
109
|
+
const thinkingBlocks = new Map();
|
|
105
110
|
let model = '';
|
|
106
111
|
let toolCalls = [];
|
|
107
112
|
let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
|
|
@@ -389,6 +394,26 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
389
394
|
inputJson: '',
|
|
390
395
|
});
|
|
391
396
|
}
|
|
397
|
+
if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
|
|
398
|
+
if (block.type === 'redacted_thinking') {
|
|
399
|
+
// Redacted blocks round-trip EXACTLY as
|
|
400
|
+
// {type:'redacted_thinking',data} — no thinking/
|
|
401
|
+
// signature fields (the API rejects the extras).
|
|
402
|
+
// `data` carries the opaque payload verbatim.
|
|
403
|
+
thinkingBlocks.set(event.index, {
|
|
404
|
+
type: 'redacted_thinking',
|
|
405
|
+
data: typeof block.data === 'string' ? block.data : '',
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
// Seed an ordered thinking block; deltas below
|
|
409
|
+
// append text + signature into this same slot.
|
|
410
|
+
thinkingBlocks.set(event.index, {
|
|
411
|
+
type: 'thinking',
|
|
412
|
+
thinking: typeof block.thinking === 'string' ? block.thinking : '',
|
|
413
|
+
signature: typeof block.signature === 'string' ? block.signature : '',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
392
417
|
}
|
|
393
418
|
|
|
394
419
|
if (event.type === 'content_block_delta') {
|
|
@@ -433,6 +458,21 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
433
458
|
// tool_use) can be classified by the loop as
|
|
434
459
|
// synthesis-stalled rather than silent empty.
|
|
435
460
|
hasThinkingContent = true;
|
|
461
|
+
// Accumulate the block content in order so it can be
|
|
462
|
+
// returned intact and round-tripped on the next turn.
|
|
463
|
+
// A signature_delta may arrive before any thinking_delta
|
|
464
|
+
// seeded the slot (display-omitted models emit only a
|
|
465
|
+
// signature) — lazily create it.
|
|
466
|
+
let tb = thinkingBlocks.get(event.index);
|
|
467
|
+
if (!tb) {
|
|
468
|
+
tb = { type: 'thinking', thinking: '', signature: '' };
|
|
469
|
+
thinkingBlocks.set(event.index, tb);
|
|
470
|
+
}
|
|
471
|
+
if (delta.type === 'thinking_delta') {
|
|
472
|
+
tb.thinking += delta.thinking || '';
|
|
473
|
+
} else {
|
|
474
|
+
tb.signature += delta.signature || '';
|
|
475
|
+
}
|
|
436
476
|
try { onStreamDelta?.(); } catch {}
|
|
437
477
|
}
|
|
438
478
|
if (delta?.type === 'input_json_delta') {
|
|
@@ -577,6 +617,15 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
577
617
|
stopReason,
|
|
578
618
|
hasThinkingContent,
|
|
579
619
|
contentBlockTypes: Array.from(contentBlockTypes),
|
|
620
|
+
// Ordered extended-thinking blocks (verbatim thinking text +
|
|
621
|
+
// signature) for round-tripping on tool-continuation turns. Emitted
|
|
622
|
+
// in content_block index order. Empty thinking + signature is a
|
|
623
|
+
// valid block (display-omitted models) and is kept intact.
|
|
624
|
+
thinkingBlocks: thinkingBlocks.size
|
|
625
|
+
? [...thinkingBlocks.entries()]
|
|
626
|
+
.sort((a, b) => a[0] - b[0])
|
|
627
|
+
.map(([, b]) => b)
|
|
628
|
+
: undefined,
|
|
580
629
|
};
|
|
581
630
|
} finally {
|
|
582
631
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -337,6 +337,14 @@ function toAnthropicMessages(messages) {
|
|
|
337
337
|
content = m.assistantBlocks.slice();
|
|
338
338
|
} else {
|
|
339
339
|
content = [];
|
|
340
|
+
// Adaptive-thinking round-trip: prior-turn thinking blocks are
|
|
341
|
+
// REQUIRED back, unmodified (signature intact; empty thinking
|
|
342
|
+
// field allowed), and MUST precede tool_use blocks.
|
|
343
|
+
if (Array.isArray(m.thinkingBlocks)) {
|
|
344
|
+
for (const tb of m.thinkingBlocks) {
|
|
345
|
+
if (tb && typeof tb === 'object') content.push(tb);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
340
348
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
341
349
|
for (const tc of m.toolCalls) {
|
|
342
350
|
content.push({
|
|
@@ -672,6 +680,12 @@ export class AnthropicProvider {
|
|
|
672
680
|
contentBlockTypes: Array.isArray(parseResult.contentBlockTypes)
|
|
673
681
|
? parseResult.contentBlockTypes
|
|
674
682
|
: [],
|
|
683
|
+
// Round-trip adaptive-thinking blocks (verbatim thinking +
|
|
684
|
+
// signature) so the loop can store them and replay them before
|
|
685
|
+
// tool_use on the next turn. Matches anthropic-oauth's return.
|
|
686
|
+
thinkingBlocks: Array.isArray(parseResult.thinkingBlocks) && parseResult.thinkingBlocks.length
|
|
687
|
+
? parseResult.thinkingBlocks
|
|
688
|
+
: undefined,
|
|
675
689
|
usage: {
|
|
676
690
|
inputTokens: input,
|
|
677
691
|
outputTokens: output,
|
|
@@ -81,6 +81,13 @@ export function messageEstimateText(m) {
|
|
|
81
81
|
try { text += `\n${JSON.stringify(m.toolCalls)}`; }
|
|
82
82
|
catch { text += `\n[${m.toolCalls.length} tool calls]`; }
|
|
83
83
|
}
|
|
84
|
+
// Anthropic adaptive-thinking blocks round-trip verbatim (thinking text +
|
|
85
|
+
// signature / redacted data) and are re-sent on tool-continuation turns, so
|
|
86
|
+
// they consume real input tokens. Count them or trim/compact undercounts.
|
|
87
|
+
if (m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
|
|
88
|
+
try { text += `\n${JSON.stringify(m.thinkingBlocks)}`; }
|
|
89
|
+
catch { text += `\n[${m.thinkingBlocks.length} thinking blocks]`; }
|
|
90
|
+
}
|
|
84
91
|
if (m.role === 'tool' && m.toolCallId) text += `\n${m.toolCallId}`;
|
|
85
92
|
return text;
|
|
86
93
|
}
|
|
@@ -1220,6 +1220,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1220
1220
|
// turn). Blank it so it never accumulates as input tokens.
|
|
1221
1221
|
content: suppressMidTurnText ? '' : (response.content || ''),
|
|
1222
1222
|
toolCalls: compactToolCallsForHistory(calls),
|
|
1223
|
+
// Anthropic adaptive thinking: prior-turn thinking blocks must be
|
|
1224
|
+
// returned verbatim (signature intact; empty thinking allowed) and
|
|
1225
|
+
// are REQUIRED back before tool_use blocks on tool-continuation
|
|
1226
|
+
// turns. Store them so toAnthropicMessages can build assistantBlocks
|
|
1227
|
+
// = [...thinking, tool_use...]. Other providers ignore this field.
|
|
1228
|
+
...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
|
|
1229
|
+
? { thinkingBlocks: response.thinkingBlocks }
|
|
1230
|
+
: {}),
|
|
1223
1231
|
...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
|
|
1224
1232
|
? { reasoningItems: response.reasoningItems }
|
|
1225
1233
|
: {}),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extracted verbatim from manager.mjs (behavior-preserving).
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
5
|
-
import { updateJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
|
|
5
|
+
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
6
6
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
7
7
|
import { loadSession } from '../store.mjs';
|
|
8
8
|
|
|
@@ -115,23 +115,33 @@ function persistPendingMessages(sessionId, messages) {
|
|
|
115
115
|
.map(pendingMessageText)
|
|
116
116
|
.filter(Boolean);
|
|
117
117
|
if (persistedMessages.length === 0) return 0;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
118
|
+
// Async lock wait: this runs on the lead/TUI main process (tool-exec +
|
|
119
|
+
// steering persist). withFileLock waits off the event loop, so cross-
|
|
120
|
+
// process contention on the shared spool never freezes the renderer.
|
|
121
|
+
// Best-effort: the returned promise is fire-and-forget; depth is reported
|
|
122
|
+
// optimistically from the buffered batch length.
|
|
123
|
+
updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
124
|
+
const next = normalizePendingStore(raw);
|
|
125
|
+
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
126
|
+
q.push(...persistedMessages);
|
|
127
|
+
next.sessions[sessionId] = q;
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
next.updatedAt = now;
|
|
130
|
+
touchPendingSessionEntry(next, sessionId, now);
|
|
131
|
+
return next;
|
|
132
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false })
|
|
133
|
+
.catch((err) => {
|
|
134
|
+
try { process.stderr.write(`[session] pending-message persist failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
135
|
+
// Requeue on failure (lock timeout/contention): buffered messages
|
|
136
|
+
// were already cleared by the flush, so push them back so the next
|
|
137
|
+
// scheduled flush or session takeover retries instead of losing them.
|
|
138
|
+
try {
|
|
139
|
+
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
140
|
+
q.push(...persistedMessages);
|
|
141
|
+
_pendingPersistBuffers.set(sessionId, q);
|
|
142
|
+
} catch {}
|
|
143
|
+
});
|
|
144
|
+
return persistedMessages.length;
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
function flushPendingMessagePersistsSync() {
|
|
@@ -175,6 +185,10 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
175
185
|
if (!isValidPendingSessionId(sessionId)) return [];
|
|
176
186
|
let drained = [];
|
|
177
187
|
try {
|
|
188
|
+
// Sync drain: called synchronously by drainPendingMessages, whose
|
|
189
|
+
// return value is spread immediately. Kept sync (updateJsonAtomicSync)
|
|
190
|
+
// so ordering/return contract is preserved; this fires only at
|
|
191
|
+
// session takeover, not on the keystroke/turn hot path.
|
|
178
192
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
179
193
|
const next = normalizePendingStore(raw);
|
|
180
194
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|