mixdog 0.9.36 → 0.9.38
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/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- 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/session-sweep.mjs +266 -0
- 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 +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- 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/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -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 +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- 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 +30 -55
- 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/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- 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/code-graph-tool-defs.mjs +1 -1
- 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/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- 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 +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- 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 +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- 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.38",
|
|
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, {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Regression tests for recoverPending recovery bugs:
|
|
2
|
+
// (a) an entry must survive when notifyFn resolves false (not delivered) and
|
|
3
|
+
// only be removed after a confirmed (truthy) ack.
|
|
4
|
+
// (b) a scoped recovery that matches purely on clientHostPid must NOT stamp
|
|
5
|
+
// the reconnecting filter session's id onto another session's abort — it
|
|
6
|
+
// delivers to the true owner session, and leaves the entry persisted when
|
|
7
|
+
// there is no owner session to target.
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
addPending,
|
|
15
|
+
recoverPending,
|
|
16
|
+
} from '../src/runtime/agent/orchestrator/dispatch-persist.mjs';
|
|
17
|
+
|
|
18
|
+
const FILE = 'pending-dispatches.json';
|
|
19
|
+
|
|
20
|
+
function readMap(dir) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(join(dir, FILE), 'utf8');
|
|
23
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
24
|
+
} catch { return {}; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitFor(fn, { timeout = 3000, step = 20 } = {}) {
|
|
28
|
+
const deadline = Date.now() + timeout;
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (fn()) return true;
|
|
31
|
+
if (Date.now() > deadline) return false;
|
|
32
|
+
await new Promise((r) => setTimeout(r, step));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tmp() {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'dpersist-'));
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('(a) only explicit false/0 keeps entry; undefined/void resolve deletes', async () => {
|
|
42
|
+
const dir = tmp();
|
|
43
|
+
try {
|
|
44
|
+
addPending(dir, 'h-a', 'recall', ['q'], 'sid-owner', 1111);
|
|
45
|
+
await waitFor(() => 'h-a' in readMap(dir));
|
|
46
|
+
|
|
47
|
+
// Explicit false → undelivered, entry MUST survive for retry.
|
|
48
|
+
recoverPending(dir, () => Promise.resolve(false), {});
|
|
49
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
50
|
+
assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=false');
|
|
51
|
+
|
|
52
|
+
// Explicit 0 → also undelivered, entry survives.
|
|
53
|
+
recoverPending(dir, () => Promise.resolve(0), {});
|
|
54
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
55
|
+
assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=0');
|
|
56
|
+
|
|
57
|
+
// undefined/void resolve from a delivered notifyFn → entry removed.
|
|
58
|
+
recoverPending(dir, () => Promise.resolve(undefined), {});
|
|
59
|
+
const gone = await waitFor(() => !('h-a' in readMap(dir)));
|
|
60
|
+
assert.ok(gone, 'entry not removed after undefined (delivered) resolve');
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('(b) hostPid-only match delivers to true owner session, never the filter session', async () => {
|
|
67
|
+
const dir = tmp();
|
|
68
|
+
try {
|
|
69
|
+
addPending(dir, 'h-b', 'recall', ['q'], 'owner-A', 4242);
|
|
70
|
+
await waitFor(() => 'h-b' in readMap(dir));
|
|
71
|
+
|
|
72
|
+
let seen = null;
|
|
73
|
+
// Reconnect as a DIFFERENT session that only shares the host pid.
|
|
74
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
75
|
+
sessionId: 'session-B',
|
|
76
|
+
clientHostPid: 4242,
|
|
77
|
+
});
|
|
78
|
+
await waitFor(() => seen != null);
|
|
79
|
+
assert.equal(seen.caller_session_id, 'owner-A', 'stamped filter session instead of true owner');
|
|
80
|
+
assert.notEqual(seen.caller_session_id, 'session-B');
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('(b) hostPid-only match with no owner session is left persisted, not injected', async () => {
|
|
87
|
+
const dir = tmp();
|
|
88
|
+
try {
|
|
89
|
+
addPending(dir, 'h-c', 'recall', ['q'], null, 5353);
|
|
90
|
+
await waitFor(() => 'h-c' in readMap(dir));
|
|
91
|
+
|
|
92
|
+
let called = false;
|
|
93
|
+
recoverPending(dir, () => { called = true; return Promise.resolve(true); }, {
|
|
94
|
+
sessionId: 'session-C',
|
|
95
|
+
clientHostPid: 5353,
|
|
96
|
+
});
|
|
97
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
98
|
+
assert.equal(called, false, 'notifyFn fired for an entry with no owner session');
|
|
99
|
+
assert.ok('h-c' in readMap(dir), 'entry with no owner session was not left persisted');
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('owner-session match still stamps the reconnecting session id', async () => {
|
|
106
|
+
const dir = tmp();
|
|
107
|
+
try {
|
|
108
|
+
addPending(dir, 'h-d', 'recall', ['q'], 'prior-sid', 6464);
|
|
109
|
+
await waitFor(() => 'h-d' in readMap(dir));
|
|
110
|
+
|
|
111
|
+
let seen = null;
|
|
112
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
113
|
+
sessionId: 'new-sid',
|
|
114
|
+
priorSessionId: 'prior-sid',
|
|
115
|
+
clientHostPid: 6464,
|
|
116
|
+
});
|
|
117
|
+
await waitFor(() => seen != null);
|
|
118
|
+
assert.equal(seen.caller_session_id, 'new-sid', 'owner match should stamp reconnecting session id');
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('priorSessionId match with no current sessionId keeps the entry owner cid', async () => {
|
|
125
|
+
const dir = tmp();
|
|
126
|
+
try {
|
|
127
|
+
addPending(dir, 'h-e', 'recall', ['q'], 'prior-sid', 7575);
|
|
128
|
+
await waitFor(() => 'h-e' in readMap(dir));
|
|
129
|
+
|
|
130
|
+
let seen = null;
|
|
131
|
+
// priorSessionId matches the owner but no current sessionId is supplied →
|
|
132
|
+
// filterSid is null; must fall back to the entry's known owner cid.
|
|
133
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
134
|
+
priorSessionId: 'prior-sid',
|
|
135
|
+
});
|
|
136
|
+
await waitFor(() => seen != null);
|
|
137
|
+
assert.equal(seen.caller_session_id, 'prior-sid', 'dropped known owner cid when sessionId absent');
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -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);
|
|
@@ -6,13 +6,47 @@
|
|
|
6
6
|
// node scripts/explore-bench.mjs [roundLabel]
|
|
7
7
|
// Fresh process per run = no in-memory result cache carryover.
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, isAbsolute } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
12
12
|
|
|
13
13
|
const ROUND = process.argv[2] || 'r0';
|
|
14
14
|
const CWD = 'C:/Project/mixdog';
|
|
15
15
|
|
|
16
|
+
// Anchor helpers: an anchor line carries a `path:line`. Strict quality =
|
|
17
|
+
// expected token must land ON such a line (not merely somewhere in the text),
|
|
18
|
+
// and anchors===0 is always a miss (except expectFail). Spot-accuracy verifies
|
|
19
|
+
// the referenced file exists and the line number is within the file length.
|
|
20
|
+
function anchorLinesOf(text) {
|
|
21
|
+
return text.split('\n').filter((l) => /:\d+/.test(l));
|
|
22
|
+
}
|
|
23
|
+
function checkAnchorLine(line, ctxTokens = []) {
|
|
24
|
+
const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
|
|
25
|
+
if (!m) return null; // no parseable path:line
|
|
26
|
+
const p = m[1];
|
|
27
|
+
const ln = Number(m[2]);
|
|
28
|
+
const full = isAbsolute(p) ? p : join(CWD, p);
|
|
29
|
+
if (!existsSync(full)) return false;
|
|
30
|
+
try {
|
|
31
|
+
const fileLines = readFileSync(full, 'utf8').split('\n');
|
|
32
|
+
if (!(ln >= 1 && ln <= fileLines.length)) return false;
|
|
33
|
+
// Fabricated-line guard: the cited line's ±2 window must contain a
|
|
34
|
+
// query/expected token, so a plausible-but-wrong line number fails.
|
|
35
|
+
if (ctxTokens.length) {
|
|
36
|
+
// ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
|
|
37
|
+
const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n').toLowerCase();
|
|
38
|
+
if (!ctxTokens.some((t) => win.includes(t))) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// High-fanout timing attribution: emit lightweight per-iteration send_ms
|
|
45
|
+
// loop rows (no verbose payload estimate) so we can split LLM send time
|
|
46
|
+
// from tool time under the parallel round below. Trace-only; no behavior
|
|
47
|
+
// or provider change.
|
|
48
|
+
if (!process.env.MIXDOG_AGENT_TRACE_TIMING) process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
|
|
49
|
+
|
|
16
50
|
// Representative locator queries with ground truth: expect = substrings, at
|
|
17
51
|
// least one must appear in the anchor output (quality hit). expectFail = the
|
|
18
52
|
// correct answer is EXPLORATION_FAILED (miss discipline).
|
|
@@ -51,17 +85,24 @@ const QUERIES = [
|
|
|
51
85
|
expect: ['agent-dispatch'],
|
|
52
86
|
},
|
|
53
87
|
{
|
|
54
|
-
q: '
|
|
88
|
+
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
89
|
expect: ['agent-loop-policy'],
|
|
56
90
|
},
|
|
57
91
|
{
|
|
58
92
|
q: 'how does a fixed agent slot map to a workflow slot (explore/explorer)',
|
|
59
|
-
expect: ['
|
|
93
|
+
expect: ['session-runtime/workflow', 'FIXED_AGENT_SLOTS'],
|
|
60
94
|
},
|
|
61
95
|
{
|
|
62
96
|
q: 'where is the mixdog config json file path resolved (data dir)',
|
|
63
97
|
expect: ['config', 'plugin-paths'],
|
|
64
98
|
},
|
|
99
|
+
{
|
|
100
|
+
// path-only class: the correct answer is a verified file/dir location on
|
|
101
|
+
// disk; a bare path (no :line) is an allowed HIT for this query.
|
|
102
|
+
q: 'where does mixdog store background shell job stdout logs on disk',
|
|
103
|
+
expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
|
|
104
|
+
pathOnly: true,
|
|
105
|
+
},
|
|
65
106
|
{
|
|
66
107
|
q: 'GraphQL schema stitching resolver implementation', // not in this repo
|
|
67
108
|
expectFail: true,
|
|
@@ -81,7 +122,9 @@ function readTraceSince(ts) {
|
|
|
81
122
|
if (!line) continue;
|
|
82
123
|
try {
|
|
83
124
|
const r = JSON.parse(line);
|
|
84
|
-
|
|
125
|
+
// Keep every row since ts; explorer-session attribution happens at
|
|
126
|
+
// aggregation time.
|
|
127
|
+
if (r.ts >= ts) rows.push(r);
|
|
85
128
|
} catch { /* skip */ }
|
|
86
129
|
}
|
|
87
130
|
return rows;
|
|
@@ -90,24 +133,49 @@ function readTraceSince(ts) {
|
|
|
90
133
|
const t0 = Date.now();
|
|
91
134
|
// Parallel round: all queries fire at once. Per-session trace attribution is
|
|
92
135
|
// by session_id so counts stay correct; per-query ms includes queueing.
|
|
93
|
-
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
|
|
136
|
+
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
|
|
94
137
|
const qt0 = Date.now();
|
|
95
138
|
const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
|
|
96
139
|
const text = res?.content?.[0]?.text || '';
|
|
97
|
-
const
|
|
140
|
+
const aLines = anchorLinesOf(text);
|
|
141
|
+
const anchors = aLines.length;
|
|
98
142
|
const failed = /EXPLORATION_FAILED/.test(text);
|
|
99
|
-
|
|
143
|
+
// Context tokens for the fabricated-line guard: expanded expected + query
|
|
144
|
+
// words, length>=4 to drop filler.
|
|
145
|
+
const ctxTokens = [
|
|
146
|
+
...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
|
|
147
|
+
...q.toLowerCase().split(/[^a-z0-9]+/),
|
|
148
|
+
].filter((t) => t.length >= 4);
|
|
149
|
+
// Spot-check up to 2 anchors per query for file/line validity + line context.
|
|
150
|
+
let badAnchors = 0;
|
|
151
|
+
for (const l of aLines.slice(0, 2)) {
|
|
152
|
+
if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
|
|
153
|
+
}
|
|
154
|
+
const allLines = text.split('\n').filter((l) => l.trim());
|
|
155
|
+
// Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
|
|
156
|
+
// the path-only class, an expected token on any verified path line HITs even
|
|
157
|
+
// without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
|
|
100
158
|
const hit = expectFail
|
|
101
159
|
? (failed && anchors === 0)
|
|
102
|
-
:
|
|
103
|
-
|
|
160
|
+
: pathOnly
|
|
161
|
+
? (!failed && (expect || []).some(
|
|
162
|
+
(e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
163
|
+
))
|
|
164
|
+
: (!failed && anchors > 0 && (expect || []).some(
|
|
165
|
+
(e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
166
|
+
));
|
|
167
|
+
return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
|
|
104
168
|
}));
|
|
105
169
|
|
|
106
|
-
//
|
|
170
|
+
// Identify explorer sessions from trace rows, then attribute both tool calls
|
|
171
|
+
// and LLM send time per session.
|
|
107
172
|
const rows = readTraceSince(t0);
|
|
173
|
+
const explorerSessions = new Set(
|
|
174
|
+
rows.filter((r) => r.agent === 'explorer').map((r) => r.session_id)
|
|
175
|
+
);
|
|
108
176
|
const bySession = new Map();
|
|
109
177
|
for (const r of rows) {
|
|
110
|
-
if (r.kind !== 'tool') continue;
|
|
178
|
+
if (r.kind !== 'tool' || !explorerSessions.has(r.session_id)) continue;
|
|
111
179
|
const s = bySession.get(r.session_id) || { calls: 0, tools: [] };
|
|
112
180
|
s.calls++; s.tools.push(r.tool_name);
|
|
113
181
|
bySession.set(r.session_id, s);
|
|
@@ -115,11 +183,30 @@ for (const r of rows) {
|
|
|
115
183
|
const callCounts = [...bySession.values()].map((s) => s.calls).sort((a, b) => a - b);
|
|
116
184
|
const p50 = callCounts[Math.floor(callCounts.length / 2)] ?? 0;
|
|
117
185
|
|
|
186
|
+
// LLM send-time attribution from loop rows (kind='loop', send_ms), emitted
|
|
187
|
+
// under MIXDOG_AGENT_TRACE_TIMING. Under the parallel round, sends overlap
|
|
188
|
+
// wall time, so send/wall > 1 quantifies provider contention.
|
|
189
|
+
const sendBySession = new Map();
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
if (r.kind !== 'loop' || !explorerSessions.has(r.session_id)) continue;
|
|
192
|
+
const cur = sendBySession.get(r.session_id) || { sends: 0, ms: 0 };
|
|
193
|
+
cur.sends++; cur.ms += Number(r.send_ms) || 0;
|
|
194
|
+
sendBySession.set(r.session_id, cur);
|
|
195
|
+
}
|
|
196
|
+
const sendMsPer = [...sendBySession.values()].map((s) => s.ms).sort((a, b) => a - b);
|
|
197
|
+
const sendTotal = sendMsPer.reduce((a, b) => a + b, 0);
|
|
198
|
+
const sendP50 = sendMsPer[Math.floor(sendMsPer.length / 2)] ?? 0;
|
|
199
|
+
const sendMax = sendMsPer[sendMsPer.length - 1] ?? 0;
|
|
200
|
+
const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
|
|
201
|
+
|
|
118
202
|
console.log(`\n=== explore-bench round=${ROUND} ===`);
|
|
119
203
|
for (const r of results) {
|
|
120
|
-
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
204
|
+
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} bad=${r.badAnchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
121
205
|
}
|
|
122
|
-
|
|
206
|
+
const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
|
|
207
|
+
console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
|
|
123
208
|
console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
|
|
124
209
|
for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
|
|
125
|
-
|
|
210
|
+
const wallMs = Date.now() - t0;
|
|
211
|
+
console.log(` llm-send: sessions=${sendBySession.size} sends=${sendCount} ms/session p50=${sendP50} max=${sendMax} total=${sendTotal}`);
|
|
212
|
+
console.log(` wall total=${(wallMs / 1000).toFixed(1)}s send/wall=${wallMs > 0 ? (sendTotal / wallMs).toFixed(2) : '0.00'}x`);
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
// Conditional find-first: only genuinely guessed fragments route through
|
|
17
|
+
// find, and in the SAME turn — project root itself is a verified scope.
|
|
18
|
+
assert.match(byName.grep.description, /project root counts as verified/i);
|
|
19
|
+
assert.match(byName.grep.description, /guessed path fragment → find first, same turn/i);
|
|
20
|
+
assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
|
|
21
|
+
assert.match(byName.glob.description, /project root is verified/i);
|
|
22
|
+
assert.match(byName.glob.description, /Guessed root\/name → find first, same turn/i);
|
|
23
|
+
assert.match(byName.find.description, /verify roots before grep\/glob/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('explorer turn-1 contract includes batched find for unknown broad targets', () => {
|
|
27
|
+
const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
|
|
28
|
+
assert.match(rule, /Turn 1 is the whole search in ONE message/i);
|
|
29
|
+
assert.match(rule, /unknown\/broad\s+targets\)\s+find `query\[\]`/i);
|
|
30
|
+
assert.match(rule, /path\/name fragments from multiple tokens/i);
|
|
31
|
+
});
|
|
@@ -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' }
|