mixdog 0.9.35 → 0.9.37
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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +15 -7
- package/src/rules/lead/01-general.md +4 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +473 -155
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/context-state.mjs +13 -4
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.37",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"smoke:tools": "node scripts/tool-smoke.mjs",
|
|
47
47
|
"smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
|
|
48
48
|
"smoke:output": "node scripts/output-style-smoke.mjs",
|
|
49
|
-
"smoke:tui": "node scripts/tui-render-smoke.mjs",
|
|
49
|
+
"smoke:tui": "node scripts/tui-render-smoke.mjs && npm run test:tui-queue",
|
|
50
50
|
"smoke:freevars": "node scripts/freevar-smoke.mjs",
|
|
51
51
|
"smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
|
|
52
52
|
"test:toolcall": "node --test scripts/toolcall-args-test.mjs",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
|
|
56
56
|
"test:providers": "node --test scripts/provider-toolcall-test.mjs",
|
|
57
57
|
"test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
|
|
58
|
+
"test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs",
|
|
58
59
|
"test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
|
|
59
60
|
"failures": "node scripts/tool-failures.mjs",
|
|
60
61
|
"trace:llm": "node scripts/llm-trace-summary.mjs",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression: bounded manual-abort recovery. Esc calls runtime.abort(), which
|
|
3
|
+
// normally rejects the in-flight runtime.ask() so the turn's finally clears
|
|
4
|
+
// busy. If that unwind is STARVED (provider abort never settles after a
|
|
5
|
+
// post-tool fetch stall), busy must not stay true forever — a short grace timer
|
|
6
|
+
// hard-releases the store and re-kicks drain. A normal abort that settles in
|
|
7
|
+
// time must NOT be masked by the recovery.
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
11
|
+
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
12
|
+
|
|
13
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
// Minimal engine bag exercising only the abort() recovery path. `abortSettles`
|
|
16
|
+
// models whether runtime.abort() actually unwinds the turn (clears busy) — the
|
|
17
|
+
// starved case leaves busy=true so the recovery timer must fire.
|
|
18
|
+
function makeEngine({ abortSettles = false, recoveryMs = 30 } = {}) {
|
|
19
|
+
let seq = 0;
|
|
20
|
+
const notices = [];
|
|
21
|
+
let drainCount = 0;
|
|
22
|
+
let state = { items: [], queued: [], busy: false, commandBusy: false, spinner: null, thinking: null, lastTurn: null };
|
|
23
|
+
const bag = {
|
|
24
|
+
runtime: {
|
|
25
|
+
id: null,
|
|
26
|
+
consumePendingSessionReset: () => null,
|
|
27
|
+
abort: () => {
|
|
28
|
+
if (abortSettles) bag.set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
nextId: () => `id_${++seq}`,
|
|
33
|
+
tuiDebug: () => {},
|
|
34
|
+
flags: { leadTurnEpoch: 1, disposed: false, draining: false, activePromptRestore: null, manualAbortRecoveryMs: recoveryMs },
|
|
35
|
+
pending: [],
|
|
36
|
+
listeners: new Set(),
|
|
37
|
+
getState: () => state,
|
|
38
|
+
set: (patch) => {
|
|
39
|
+
if (!patch || typeof patch !== 'object') return false;
|
|
40
|
+
state = { ...state, ...patch };
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
pushItem: () => {},
|
|
44
|
+
patchItem: () => {},
|
|
45
|
+
replaceItems: (x) => x,
|
|
46
|
+
pushNotice: (text, level) => { notices.push({ text, level }); },
|
|
47
|
+
pushUserOrSyntheticItem: () => {},
|
|
48
|
+
autoClearState: () => ({ enabled: false }),
|
|
49
|
+
agentStatusState: () => ({}),
|
|
50
|
+
routeState: () => ({}),
|
|
51
|
+
syncContextStats: () => {},
|
|
52
|
+
denyAllToolApprovals: () => {},
|
|
53
|
+
updateAgentJobCard: () => {},
|
|
54
|
+
requeueEntriesFront: () => {},
|
|
55
|
+
resetStatsAndSyncContext: () => {},
|
|
56
|
+
flushDeferredExecutionPendingResumeKick: () => {},
|
|
57
|
+
drain: async () => { drainCount += 1; },
|
|
58
|
+
runTurn: async () => 'ok',
|
|
59
|
+
};
|
|
60
|
+
Object.assign(bag, createSessionFlow(bag));
|
|
61
|
+
bag.drain = async () => { drainCount += 1; };
|
|
62
|
+
const api = createEngineApiA(bag);
|
|
63
|
+
return { api, bag, getNotices: () => notices, getDrainCount: () => drainCount };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('starved abort → bounded recovery hard-releases busy and re-kicks drain', async () => {
|
|
67
|
+
const { api, bag, getNotices, getDrainCount } = makeEngine({ abortSettles: false, recoveryMs: 25 });
|
|
68
|
+
bag.set({ busy: true, spinner: { active: true } });
|
|
69
|
+
bag.pending.push({ kind: 'prompt', text: 'queued next' });
|
|
70
|
+
const res = api.abort();
|
|
71
|
+
assert.equal(res.aborted, true, 'abort dispatched to runtime');
|
|
72
|
+
assert.equal(bag.getState().busy, true, 'still busy immediately after abort (unwind pending)');
|
|
73
|
+
await wait(60);
|
|
74
|
+
assert.equal(bag.getState().busy, false, 'recovery timer force-releases busy');
|
|
75
|
+
assert.equal(bag.getState().spinner, null, 'spinner cleared on recovery');
|
|
76
|
+
assert.equal(bag.flags.leadTurnEpoch, 2, 'epoch bumped so stuck turn finally becomes a no-op');
|
|
77
|
+
assert.equal(getDrainCount() >= 1, true, 'drain re-kicked so queued prompt runs');
|
|
78
|
+
assert.equal(getNotices().some((n) => /did not settle/i.test(n.text)), true, 'user told input was restored');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('starved abort abandons old drain owner before re-kicking drain', async () => {
|
|
82
|
+
const { api, bag, getDrainCount } = makeEngine({ abortSettles: false, recoveryMs: 25 });
|
|
83
|
+
bag.flags.draining = true;
|
|
84
|
+
bag.flags.drainEpoch = 10;
|
|
85
|
+
bag.set({ busy: true, spinner: { active: true } });
|
|
86
|
+
bag.pending.push({ kind: 'prompt', text: 'queued next' });
|
|
87
|
+
|
|
88
|
+
api.abort();
|
|
89
|
+
await wait(60);
|
|
90
|
+
|
|
91
|
+
assert.equal(bag.getState().busy, false);
|
|
92
|
+
assert.equal(bag.flags.draining, false, 'stuck drain lock was released only after epoch abandonment');
|
|
93
|
+
assert.equal(bag.flags.drainEpoch > 10, true, 'old drain owner invalidated');
|
|
94
|
+
assert.equal(getDrainCount() >= 1, true, 'new drain kick requested for pending work');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('abort that settles in time is NOT masked by recovery', async () => {
|
|
98
|
+
const { api, bag, getNotices } = makeEngine({ abortSettles: true, recoveryMs: 25 });
|
|
99
|
+
bag.set({ busy: true, spinner: { active: true } });
|
|
100
|
+
const res = api.abort();
|
|
101
|
+
assert.equal(res.aborted, true);
|
|
102
|
+
assert.equal(bag.getState().busy, false, 'settled abort cleared busy synchronously');
|
|
103
|
+
const epochAfter = bag.flags.leadTurnEpoch;
|
|
104
|
+
await wait(60);
|
|
105
|
+
assert.equal(bag.flags.leadTurnEpoch, epochAfter, 'recovery no-ops (no epoch bump) when busy already cleared');
|
|
106
|
+
assert.equal(getNotices().some((n) => /did not settle/i.test(n.text)), false, 'no spurious recovery notice');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('recovery no-ops if a newer turn already owns the store', async () => {
|
|
110
|
+
const { api, bag } = makeEngine({ abortSettles: false, recoveryMs: 25 });
|
|
111
|
+
bag.set({ busy: true });
|
|
112
|
+
api.abort();
|
|
113
|
+
bag.flags.leadTurnEpoch = 5;
|
|
114
|
+
bag.set({ busy: true, spinner: { active: true, verb: 'new turn' } });
|
|
115
|
+
await wait(60);
|
|
116
|
+
assert.equal(bag.flags.leadTurnEpoch, 5, 'recovery must not touch a newer turn epoch');
|
|
117
|
+
assert.equal(bag.getState().busy, true, 'newer turn stays busy — not force-released');
|
|
118
|
+
});
|
|
@@ -858,7 +858,7 @@ function chars4(text) {
|
|
|
858
858
|
return Math.ceil(String(text ?? '').length / 4);
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
-
const koHeavy = '
|
|
861
|
+
const koHeavy = '\uD55C\uAD6D\uC5B4 \uCEF4\uD329\uC158 \uACBD\uACC4 \uD14C\uC2A4\uD2B8 '.repeat(200);
|
|
862
862
|
const koEst = estimateMessagesTokens([{ role: 'user', content: koHeavy }]);
|
|
863
863
|
assert(
|
|
864
864
|
koEst > chars4(koHeavy) * 2,
|
|
@@ -892,8 +892,8 @@ assert(
|
|
|
892
892
|
// also use the conservative estimator (>= chars/4 of the serialized JSON).
|
|
893
893
|
const toolSchema = [{
|
|
894
894
|
name: 'apply_patch',
|
|
895
|
-
description: '
|
|
896
|
-
parameters: { type: 'object', properties: { patch: { type: 'string', description: '
|
|
895
|
+
description: '\uD328\uCE58\uB97C \uD30C\uC77C\uC5D0 \uC801\uC6A9 (\uD55C\uAD6D\uC5B4 \uC124\uBA85) — applies a patch',
|
|
896
|
+
parameters: { type: 'object', properties: { patch: { type: 'string', description: '\uD328\uCE58 \uBCF8\uBB38 \uD14D\uC2A4\uD2B8' } } },
|
|
897
897
|
}];
|
|
898
898
|
const toolSchemaEst = estimateToolSchemaTokens(toolSchema);
|
|
899
899
|
assert(
|
|
@@ -904,7 +904,7 @@ assert(
|
|
|
904
904
|
// Strict-fit smoke: a Korean/CJK-heavy newest turn far larger than the preserve
|
|
905
905
|
// budget must be summarized/truncated safely — never preserved verbatim — and
|
|
906
906
|
// the final estimated tokens must stay within budget.
|
|
907
|
-
const KO_HUGE_SENTINEL = 'KO_HUGE_SENTINEL_' + '
|
|
907
|
+
const KO_HUGE_SENTINEL = 'KO_HUGE_SENTINEL_' + '\uC555\uCD95\uACBD\uACC4\uD14C\uC2A4\uD2B8'.repeat(20_000);
|
|
908
908
|
let strictFitPrompt = '';
|
|
909
909
|
const strictFitProvider = {
|
|
910
910
|
name: 'strict-fit-cjk-smoke',
|
|
@@ -915,9 +915,9 @@ const strictFitProvider = {
|
|
|
915
915
|
};
|
|
916
916
|
const strictFitMessages = [
|
|
917
917
|
{ role: 'system', content: 'system rules stay mandatory' },
|
|
918
|
-
{ role: 'user', content: '
|
|
919
|
-
{ role: 'assistant', content: '
|
|
920
|
-
{ role: 'user', content:
|
|
918
|
+
{ role: 'user', content: '\uC774\uC804 \uC791\uC740 \uC694\uCCAD' },
|
|
919
|
+
{ role: 'assistant', content: '\uC774\uC804 \uC791\uC740 \uC751\uB2F5' },
|
|
920
|
+
{ role: 'user', content: `\uCD5C\uC2E0 \uAC70\uB300\uD55C \uD55C\uAD6D\uC5B4 \uC694\uCCAD ${KO_HUGE_SENTINEL}` },
|
|
921
921
|
];
|
|
922
922
|
const STRICT_FIT_BUDGET = 6_000;
|
|
923
923
|
const strictFitResult = await semanticCompactMessages(strictFitProvider, strictFitMessages, 'fake-model', STRICT_FIT_BUDGET, {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
1
4
|
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
5
|
+
// Attribute LLM send time (not just wall) for this high-fanout batch.
|
|
6
|
+
process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
|
|
2
7
|
const queries = [
|
|
3
8
|
'standalone explore tool implementation entry point',
|
|
4
9
|
'V4A patch format parsing/conversion implementation',
|
|
@@ -14,4 +19,18 @@ const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { cal
|
|
|
14
19
|
console.log('BATCH_ELAPSED_MS', Date.now() - t0);
|
|
15
20
|
const txt = res.content?.[0]?.text || '';
|
|
16
21
|
console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
|
|
22
|
+
// Sum send_ms from loop rows emitted since t0 → LLM time vs the wall above.
|
|
23
|
+
const tracePath = process.env.MIXDOG_AGENT_TRACE_PATH
|
|
24
|
+
|| join(process.env.MIXDOG_DATA_DIR || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data'), 'history', 'agent-trace.jsonl');
|
|
25
|
+
let sendMs = 0, sends = 0; const sessions = new Set();
|
|
26
|
+
if (existsSync(tracePath)) {
|
|
27
|
+
for (const line of readFileSync(tracePath, 'utf8').split('\n')) {
|
|
28
|
+
if (!line) continue;
|
|
29
|
+
try {
|
|
30
|
+
const r = JSON.parse(line);
|
|
31
|
+
if (r.ts >= t0 && r.kind === 'loop' && r.agent === 'explorer') { sendMs += Number(r.send_ms) || 0; sends++; sessions.add(r.session_id); }
|
|
32
|
+
} catch { /* skip */ }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
console.log('LLM_SEND_MS_TOTAL', sendMs, 'sends', sends, 'sessions', sessions.size);
|
|
17
36
|
process.exit(0);
|
|
@@ -13,6 +13,12 @@ import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
|
13
13
|
const ROUND = process.argv[2] || 'r0';
|
|
14
14
|
const CWD = 'C:/Project/mixdog';
|
|
15
15
|
|
|
16
|
+
// High-fanout timing attribution: emit lightweight per-iteration send_ms
|
|
17
|
+
// loop rows (no verbose payload estimate) so we can split LLM send time
|
|
18
|
+
// from tool time under the parallel round below. Trace-only; no behavior
|
|
19
|
+
// or provider change.
|
|
20
|
+
if (!process.env.MIXDOG_AGENT_TRACE_TIMING) process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
|
|
21
|
+
|
|
16
22
|
// Representative locator queries with ground truth: expect = substrings, at
|
|
17
23
|
// least one must appear in the anchor output (quality hit). expectFail = the
|
|
18
24
|
// correct answer is EXPLORATION_FAILED (miss discipline).
|
|
@@ -51,12 +57,12 @@ const QUERIES = [
|
|
|
51
57
|
expect: ['agent-dispatch'],
|
|
52
58
|
},
|
|
53
59
|
{
|
|
54
|
-
q: '
|
|
60
|
+
q: '\uC5D0\uC774\uC804\uD2B8 \uC138\uC158\uC758 \uCD5C\uB300 \uB8E8\uD504 \uBC18\uBCF5 \uD69F\uC218\uB294 \uC5B4\uB514\uC11C \uC815\uD574\uC838?', // korean concept query
|
|
55
61
|
expect: ['agent-loop-policy'],
|
|
56
62
|
},
|
|
57
63
|
{
|
|
58
64
|
q: 'how does a fixed agent slot map to a workflow slot (explore/explorer)',
|
|
59
|
-
expect: ['
|
|
65
|
+
expect: ['session-runtime/workflow', 'FIXED_AGENT_SLOTS'],
|
|
60
66
|
},
|
|
61
67
|
{
|
|
62
68
|
q: 'where is the mixdog config json file path resolved (data dir)',
|
|
@@ -81,7 +87,9 @@ function readTraceSince(ts) {
|
|
|
81
87
|
if (!line) continue;
|
|
82
88
|
try {
|
|
83
89
|
const r = JSON.parse(line);
|
|
84
|
-
|
|
90
|
+
// Keep every row since ts; explorer-session attribution happens at
|
|
91
|
+
// aggregation time.
|
|
92
|
+
if (r.ts >= ts) rows.push(r);
|
|
85
93
|
} catch { /* skip */ }
|
|
86
94
|
}
|
|
87
95
|
return rows;
|
|
@@ -103,11 +111,15 @@ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail })
|
|
|
103
111
|
return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
|
|
104
112
|
}));
|
|
105
113
|
|
|
106
|
-
//
|
|
114
|
+
// Identify explorer sessions from trace rows, then attribute both tool calls
|
|
115
|
+
// and LLM send time per session.
|
|
107
116
|
const rows = readTraceSince(t0);
|
|
117
|
+
const explorerSessions = new Set(
|
|
118
|
+
rows.filter((r) => r.agent === 'explorer').map((r) => r.session_id)
|
|
119
|
+
);
|
|
108
120
|
const bySession = new Map();
|
|
109
121
|
for (const r of rows) {
|
|
110
|
-
if (r.kind !== 'tool') continue;
|
|
122
|
+
if (r.kind !== 'tool' || !explorerSessions.has(r.session_id)) continue;
|
|
111
123
|
const s = bySession.get(r.session_id) || { calls: 0, tools: [] };
|
|
112
124
|
s.calls++; s.tools.push(r.tool_name);
|
|
113
125
|
bySession.set(r.session_id, s);
|
|
@@ -115,6 +127,22 @@ for (const r of rows) {
|
|
|
115
127
|
const callCounts = [...bySession.values()].map((s) => s.calls).sort((a, b) => a - b);
|
|
116
128
|
const p50 = callCounts[Math.floor(callCounts.length / 2)] ?? 0;
|
|
117
129
|
|
|
130
|
+
// LLM send-time attribution from loop rows (kind='loop', send_ms), emitted
|
|
131
|
+
// under MIXDOG_AGENT_TRACE_TIMING. Under the parallel round, sends overlap
|
|
132
|
+
// wall time, so send/wall > 1 quantifies provider contention.
|
|
133
|
+
const sendBySession = new Map();
|
|
134
|
+
for (const r of rows) {
|
|
135
|
+
if (r.kind !== 'loop' || !explorerSessions.has(r.session_id)) continue;
|
|
136
|
+
const cur = sendBySession.get(r.session_id) || { sends: 0, ms: 0 };
|
|
137
|
+
cur.sends++; cur.ms += Number(r.send_ms) || 0;
|
|
138
|
+
sendBySession.set(r.session_id, cur);
|
|
139
|
+
}
|
|
140
|
+
const sendMsPer = [...sendBySession.values()].map((s) => s.ms).sort((a, b) => a - b);
|
|
141
|
+
const sendTotal = sendMsPer.reduce((a, b) => a + b, 0);
|
|
142
|
+
const sendP50 = sendMsPer[Math.floor(sendMsPer.length / 2)] ?? 0;
|
|
143
|
+
const sendMax = sendMsPer[sendMsPer.length - 1] ?? 0;
|
|
144
|
+
const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
|
|
145
|
+
|
|
118
146
|
console.log(`\n=== explore-bench round=${ROUND} ===`);
|
|
119
147
|
for (const r of results) {
|
|
120
148
|
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
@@ -122,4 +150,6 @@ for (const r of results) {
|
|
|
122
150
|
console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits`);
|
|
123
151
|
console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
|
|
124
152
|
for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
|
|
125
|
-
|
|
153
|
+
const wallMs = Date.now() - t0;
|
|
154
|
+
console.log(` llm-send: sessions=${sendBySession.size} sends=${sendCount} ms/session p50=${sendP50} max=${sendMax} total=${sendTotal}`);
|
|
155
|
+
console.log(` wall total=${(wallMs / 1000).toFixed(1)}s send/wall=${wallMs > 0 ? (sendTotal / wallMs).toFixed(2) : '0.00'}x`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { buildExplorerPrompt } from '../src/standalone/explore-tool.mjs';
|
|
6
|
+
import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
|
|
7
|
+
|
|
8
|
+
test('explore per-query prompt leaves path policy to tool schemas', () => {
|
|
9
|
+
const prompt = buildExplorerPrompt('display model usage show usage model_usage provider_usage session cache usage state');
|
|
10
|
+
assert.doesNotMatch(prompt, /Path policy:/i);
|
|
11
|
+
assert.doesNotMatch(prompt, /run find for that fragment before any grep\/glob/i);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('grep glob find tool schemas carry unverified path policy', () => {
|
|
15
|
+
const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
|
|
16
|
+
assert.match(byName.grep.description, /Unknown path\/name → find first/i);
|
|
17
|
+
assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
|
|
18
|
+
assert.match(byName.glob.description, /Unknown root\/name → find first/i);
|
|
19
|
+
assert.match(byName.find.description, /verify roots before grep\/glob/i);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('explorer turn-1 contract includes batched find for unknown broad targets', () => {
|
|
23
|
+
const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
|
|
24
|
+
assert.match(rule, /Turn 1 is the whole search in ONE message/i);
|
|
25
|
+
assert.match(rule, /unknown\/broad\s+targets\)\s+find `query\[\]`/i);
|
|
26
|
+
assert.match(rule, /path\/name fragments from multiple tokens/i);
|
|
27
|
+
});
|
|
@@ -46,7 +46,7 @@ const prefixedUser = {
|
|
|
46
46
|
`${sessionBlock}\n\n` +
|
|
47
47
|
'# Additional context\nsome context body line one\nline two\n\n' +
|
|
48
48
|
'# Prefetch\nprefetched file snippet\n\n' +
|
|
49
|
-
'# Task\n
|
|
49
|
+
'# Task\n\uC2E4\uC81C \uC9C8\uBB38',
|
|
50
50
|
}
|
|
51
51
|
// (iii) Reference files synthetic row.
|
|
52
52
|
const referenceRow = { role: 'user', content: 'Reference files:\n- a.mjs\n- b.mjs' }
|
|
@@ -76,8 +76,8 @@ const unquotedCompletionRow = {
|
|
|
76
76
|
'Result:\nbackground task\ntask_id: task_456\nstatus: completed',
|
|
77
77
|
}
|
|
78
78
|
// (vii) normal human + normal assistant.
|
|
79
|
-
const normalUser = { role: 'user', content: '
|
|
80
|
-
const normalAsst = { role: 'assistant', content: '
|
|
79
|
+
const normalUser = { role: 'user', content: '\uC548\uB155' }
|
|
80
|
+
const normalAsst = { role: 'assistant', content: '\uB124 \uC548\uB155\uD558\uC138\uC694' }
|
|
81
81
|
|
|
82
82
|
const before = {
|
|
83
83
|
asstWithTool: sessionMessageContent(asstWithTool),
|
|
@@ -111,7 +111,7 @@ assert(after.asstWithTool && !after.asstWithTool.includes('[tool_call'), 'ingest
|
|
|
111
111
|
assert(after.asstWithTool.includes('Here is my plan for the refactor.'), 'assistant prose must survive')
|
|
112
112
|
|
|
113
113
|
// (ii) prefixed user collapses to exactly the human prompt.
|
|
114
|
-
assert(after.prefixedUser === '
|
|
114
|
+
assert(after.prefixedUser === '\uC2E4\uC81C \uC9C8\uBB38', `prefixed user must shape to "\uC2E4\uC81C \uC9C8\uBB38" (got: ${JSON.stringify(after.prefixedUser)})`)
|
|
115
115
|
assert(!/# Session|Cwd:|Model:|Additional context|Prefetch|# Task/.test(after.prefixedUser), 'no prefix residue may remain')
|
|
116
116
|
|
|
117
117
|
// (iii)-(vi) synthetic rows excluded.
|
|
@@ -123,8 +123,8 @@ assert(after.completionWrapperRow === null, 'model-visible tool-completion wrapp
|
|
|
123
123
|
assert(after.unquotedCompletionRow === null, 'unquoted tool-completion wrapper row (real DB shape) must be excluded')
|
|
124
124
|
|
|
125
125
|
// (vii) normal conversation survives intact.
|
|
126
|
-
assert(after.normalUser === '
|
|
127
|
-
assert(after.normalAsst === '
|
|
126
|
+
assert(after.normalUser === '\uC548\uB155', 'normal human text must survive intact')
|
|
127
|
+
assert(after.normalAsst === '\uB124 \uC548\uB155\uD558\uC138\uC694', 'normal assistant text must survive intact')
|
|
128
128
|
|
|
129
129
|
// Zero-loss guard: a human message that merely MENTIONS the markers mid-text is
|
|
130
130
|
// NOT stripped (anchors only fire on the leading manager-produced prefix).
|
|
@@ -137,18 +137,18 @@ assert(ingestRow(humanMentionsMarkers) === 'please write a # Task section and a
|
|
|
137
137
|
// buildSessionStartBlock emits (`# Session` then only Cwd/Model/Workflow lines).
|
|
138
138
|
// Assert on the SHAPER directly so the check isolates the strip rule from
|
|
139
139
|
// cleanMemoryText's normal markdown-header cleaning.
|
|
140
|
-
const humanSessionDoc = { role: 'user', content: '# Session\n
|
|
140
|
+
const humanSessionDoc = { role: 'user', content: '# Session\n\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4\n\uB2E4\uC74C \uC548\uAC74' }
|
|
141
141
|
assert(
|
|
142
|
-
sessionMessageContentForIngest(humanSessionDoc) === '# Session\n
|
|
142
|
+
sessionMessageContentForIngest(humanSessionDoc) === '# Session\n\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4\n\uB2E4\uC74C \uC548\uAC74',
|
|
143
143
|
'human doc starting with `# Session` heading must be preserved verbatim (not wiped)',
|
|
144
144
|
)
|
|
145
145
|
// And through the full ingest row it must still carry the human words (the lone
|
|
146
146
|
// `# ` heading marker is stripped by normal markdown cleaning — not data loss).
|
|
147
|
-
assert(
|
|
147
|
+
assert(/\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4/.test(ingestRow(humanSessionDoc) || ''), 'human session-doc words must survive ingest')
|
|
148
148
|
|
|
149
149
|
// Injected real session block (Cwd/Model) still strips to the human prompt.
|
|
150
|
-
const injectedCwdModel = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\n\n
|
|
151
|
-
assert(sessionMessageContentForIngest(injectedCwdModel) === '
|
|
150
|
+
const injectedCwdModel = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\n\n\uC2E4\uC81C\uC9C8\uBB38' }
|
|
151
|
+
assert(sessionMessageContentForIngest(injectedCwdModel) === '\uC2E4\uC81C\uC9C8\uBB38', 'injected Cwd/Model session block must strip to the human prompt')
|
|
152
152
|
|
|
153
153
|
// Injected real session block incl. Workflow line still strips.
|
|
154
154
|
const injectedWorkflow = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\nWorkflow: Default\n\nq' }
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Regression: OpenAI OAuth WS early tool-call settle.
|
|
2
|
+
//
|
|
3
|
+
// A tool-call response can be fully formed (function_call_arguments.done +
|
|
4
|
+
// output_item.done) yet the server never emits the terminal
|
|
5
|
+
// response.completed/response.done frame. Before the fix _streamResponse
|
|
6
|
+
// coasted on that silence until a stall error (gated out of the loop via
|
|
7
|
+
// pendingToolUse) or the 30-min agent watchdog, and -- because the socket was
|
|
8
|
+
// still pooled -- the missing terminal frame later leaked onto the NEXT request
|
|
9
|
+
// as orphan bytes (http_status=0 wedge). The fix resolves early with the
|
|
10
|
+
// captured tool call and flags `closeSocket` so the pool discards the socket.
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
|
|
15
|
+
|
|
16
|
+
// Minimal fake WS: matches the .on/.off/.close/.ping surface _streamResponse
|
|
17
|
+
// uses. close() is a no-op here -- the early-settle path sets done=true before
|
|
18
|
+
// calling it, so the real closeHandler would short-circuit anyway.
|
|
19
|
+
class FakeSocket extends EventEmitter {
|
|
20
|
+
constructor() {
|
|
21
|
+
super();
|
|
22
|
+
this.readyState = 1;
|
|
23
|
+
this.closed = null;
|
|
24
|
+
}
|
|
25
|
+
close(code, reason) { this.closed = { code, reason }; }
|
|
26
|
+
ping() {}
|
|
27
|
+
feed(events) { for (const e of events) this.emit('message', JSON.stringify(e)); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FAST_TIMEOUTS = { interChunkMs: 40, preResponseCreatedMs: 5000, firstMeaningfulMs: 5000 };
|
|
31
|
+
|
|
32
|
+
const TOOL_CALL_EVENTS = [
|
|
33
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
34
|
+
{ type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1' } },
|
|
35
|
+
{ type: 'response.function_call_arguments.delta', item_id: 'fc_1', delta: '{"query"' },
|
|
36
|
+
{ type: 'response.function_call_arguments.done', item_id: 'fc_1', arguments: '{"query":"x"}', call_id: 'call_1', name: 'explore' },
|
|
37
|
+
{ type: 'response.output_item.done', item: { type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1', arguments: '{"query":"x"}' } },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
test('ws early settle: complete tool call resolves without response.completed and flags closeSocket', async () => {
|
|
41
|
+
const socket = new FakeSocket();
|
|
42
|
+
const state = {};
|
|
43
|
+
const emitted = [];
|
|
44
|
+
const p = _streamResponse({
|
|
45
|
+
entry: { socket },
|
|
46
|
+
state,
|
|
47
|
+
onToolCall: (c) => emitted.push(c),
|
|
48
|
+
_timeouts: FAST_TIMEOUTS,
|
|
49
|
+
});
|
|
50
|
+
socket.feed(TOOL_CALL_EVENTS);
|
|
51
|
+
const result = await p;
|
|
52
|
+
assert.equal(result.closeSocket, true, 'socket must be dropped, not pooled');
|
|
53
|
+
assert.ok(Array.isArray(result.toolCalls) && result.toolCalls.length === 1);
|
|
54
|
+
assert.equal(result.toolCalls[0].id, 'call_1');
|
|
55
|
+
assert.equal(result.toolCalls[0].name, 'explore');
|
|
56
|
+
assert.deepEqual(result.toolCalls[0].arguments, { query: 'x' });
|
|
57
|
+
assert.equal(emitted.length, 1, 'tool call dispatched exactly once');
|
|
58
|
+
assert.equal(state.wsEarlySettle, 'inter_chunk');
|
|
59
|
+
assert.ok(socket.closed, 'socket.close() invoked so it is never reused');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('ws early settle: normal response.completed path keeps the socket (no closeSocket)', async () => {
|
|
63
|
+
const socket = new FakeSocket();
|
|
64
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
65
|
+
socket.feed([
|
|
66
|
+
...TOOL_CALL_EVENTS,
|
|
67
|
+
{
|
|
68
|
+
type: 'response.completed',
|
|
69
|
+
response: {
|
|
70
|
+
id: 'resp_1',
|
|
71
|
+
model: 'gpt-5.5',
|
|
72
|
+
output: [{ type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1', arguments: '{"query":"x"}' }],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
const result = await p;
|
|
77
|
+
assert.equal(result.closeSocket, undefined, 'normal completion must not mark closeSocket');
|
|
78
|
+
assert.equal(result.toolCalls.length, 1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('ws early settle: deferred salvage still pending does NOT early-settle (fails as stall)', async () => {
|
|
82
|
+
const socket = new FakeSocket();
|
|
83
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
84
|
+
// No output_item.added -> pendingCalls empty; args.done carries no call_id/name
|
|
85
|
+
// -> a deferred placeholder that only response.completed could salvage.
|
|
86
|
+
socket.feed([
|
|
87
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
88
|
+
{ type: 'response.function_call_arguments.delta', item_id: 'fc_1', delta: '{"query"' },
|
|
89
|
+
{ type: 'response.function_call_arguments.done', item_id: 'fc_1', arguments: '{"query":"x"}' },
|
|
90
|
+
]);
|
|
91
|
+
await assert.rejects(p, /inter-chunk inactivity/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('ws early settle: complete tool then partial second tool does NOT early-settle', async () => {
|
|
95
|
+
const socket = new FakeSocket();
|
|
96
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
97
|
+
socket.feed([
|
|
98
|
+
...TOOL_CALL_EVENTS,
|
|
99
|
+
{ type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_2', name: 'grep', call_id: 'call_2' } },
|
|
100
|
+
{ type: 'response.function_call_arguments.delta', item_id: 'fc_2', delta: '{"pattern"' },
|
|
101
|
+
]);
|
|
102
|
+
await assert.rejects(p, /inter-chunk inactivity/);
|
|
103
|
+
assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'partial second tool must use stall path, not early settle');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('ws early settle: complete tool_search_call clears in-flight and can early-settle', async () => {
|
|
107
|
+
const socket = new FakeSocket();
|
|
108
|
+
const state = {};
|
|
109
|
+
const p = _streamResponse({ entry: { socket }, state, _timeouts: FAST_TIMEOUTS });
|
|
110
|
+
socket.feed([
|
|
111
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
112
|
+
{ type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_1' } },
|
|
113
|
+
{ type: 'response.output_item.done', item: { type: 'tool_search_call', id: 'ts_1', arguments: '{"query":"fetch"}' } },
|
|
114
|
+
]);
|
|
115
|
+
const result = await p;
|
|
116
|
+
assert.equal(result.closeSocket, true);
|
|
117
|
+
assert.equal(result.toolCalls[0].id, 'ts_1');
|
|
118
|
+
assert.equal(result.toolCalls[0].name, 'load_tool');
|
|
119
|
+
assert.deepEqual(result.toolCalls[0].arguments, { query: 'fetch' });
|
|
120
|
+
assert.equal(state.wsEarlySettle, 'inter_chunk');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('ws early settle: complete tool_search_call plus partial custom tool does NOT early-settle', async () => {
|
|
124
|
+
const socket = new FakeSocket();
|
|
125
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
126
|
+
socket.feed([
|
|
127
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
128
|
+
{ type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_1' } },
|
|
129
|
+
{ type: 'response.output_item.done', item: { type: 'tool_search_call', id: 'ts_1', arguments: '{"query":"fetch"}' } },
|
|
130
|
+
{ type: 'response.output_item.added', item: { type: 'custom_tool_call', id: 'ct_2', call_id: 'call_custom_2', name: 'apply_patch' } },
|
|
131
|
+
{ type: 'response.custom_tool_call_input.delta', item_id: 'ct_2', delta: '*** Begin Patch' },
|
|
132
|
+
]);
|
|
133
|
+
await assert.rejects(p, /inter-chunk inactivity/);
|
|
134
|
+
assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'partial custom tool must keep stall path');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('ws early settle: output_item.done salvages deferred function_call and can early-settle', async () => {
|
|
138
|
+
const socket = new FakeSocket();
|
|
139
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
140
|
+
socket.feed([
|
|
141
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
142
|
+
{ type: 'response.function_call_arguments.delta', item_id: 'fc_salvage', delta: '{"query"' },
|
|
143
|
+
{ type: 'response.function_call_arguments.done', item_id: 'fc_salvage', arguments: '{"query":"x"}' },
|
|
144
|
+
{ type: 'response.output_item.done', item: { type: 'function_call', id: 'fc_salvage', name: 'explore', call_id: 'call_salvage', arguments: '{"query":"x"}' } },
|
|
145
|
+
]);
|
|
146
|
+
const result = await p;
|
|
147
|
+
assert.equal(result.closeSocket, true);
|
|
148
|
+
assert.equal(result.toolCalls[0].id, 'call_salvage');
|
|
149
|
+
assert.equal(result.toolCalls[0].name, 'explore');
|
|
150
|
+
assert.deepEqual(result.toolCalls[0].arguments, { query: 'x' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('ws early settle: call_id-only tool_search done clears id-tracked active item', async () => {
|
|
154
|
+
const socket = new FakeSocket();
|
|
155
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
156
|
+
socket.feed([
|
|
157
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
158
|
+
{ type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_added', call_id: 'ts_call' } },
|
|
159
|
+
{ type: 'response.output_item.done', item: { type: 'tool_search_call', call_id: 'ts_call', arguments: '{"query":"fetch"}' } },
|
|
160
|
+
]);
|
|
161
|
+
const result = await p;
|
|
162
|
+
assert.equal(result.closeSocket, true);
|
|
163
|
+
assert.equal(result.toolCalls[0].id, 'ts_call');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('ws early settle: function args.done without item.done does NOT early-settle', async () => {
|
|
167
|
+
const socket = new FakeSocket();
|
|
168
|
+
const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
|
|
169
|
+
socket.feed([
|
|
170
|
+
{ type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
|
|
171
|
+
{ type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_empty', name: 'explore', call_id: 'call_empty' } },
|
|
172
|
+
{ type: 'response.function_call_arguments.done', item_id: 'fc_empty', arguments: '{}', call_id: 'call_empty', name: 'explore' },
|
|
173
|
+
]);
|
|
174
|
+
await assert.rejects(p, /inter-chunk inactivity/);
|
|
175
|
+
assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'function item must stay active until output_item.done');
|
|
176
|
+
});
|