muonroi-cli 1.6.0 → 1.6.2
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/dist/src/cli/cost-forensics.d.ts +3 -0
- package/dist/src/cli/cost-forensics.js +11 -0
- package/dist/src/cli/cost-forensics.test.js +1 -0
- package/dist/src/cli/experience-report.d.ts +20 -0
- package/dist/src/cli/experience-report.js +76 -0
- package/dist/src/cli/experience-report.test.d.ts +5 -0
- package/dist/src/cli/experience-report.test.js +63 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/gsd/__tests__/directives.test.js +24 -1
- package/dist/src/gsd/directives.d.ts +22 -0
- package/dist/src/gsd/directives.js +34 -10
- package/dist/src/index.js +9 -0
- package/dist/src/mcp/__tests__/client-pool.spec.js +54 -4
- package/dist/src/mcp/__tests__/forensics-tools.test.js +1 -0
- package/dist/src/mcp/client-pool.d.ts +9 -2
- package/dist/src/mcp/client-pool.js +60 -21
- package/dist/src/orchestrator/message-processor.js +34 -2
- package/dist/src/orchestrator/session-experience.d.ts +89 -0
- package/dist/src/orchestrator/session-experience.js +169 -0
- package/dist/src/orchestrator/session-experience.test.d.ts +6 -0
- package/dist/src/orchestrator/session-experience.test.js +72 -0
- package/dist/src/orchestrator/stream-runner.js +4 -0
- package/dist/src/orchestrator/subagent-compactor.d.ts +10 -0
- package/dist/src/orchestrator/subagent-compactor.js +14 -0
- package/dist/src/orchestrator/subagent-compactor.spec.js +54 -0
- package/dist/src/pil/__tests__/layer3-ee-injection.test.js +5 -3
- package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +31 -0
- package/dist/src/pil/__tests__/pipeline.test.js +17 -0
- package/dist/src/pil/layer3-ee-injection.d.ts +9 -0
- package/dist/src/pil/layer3-ee-injection.js +29 -0
- package/dist/src/pil/layer4-gsd.js +3 -2
- package/dist/src/pil/pipeline.js +11 -0
- package/dist/src/pil/session-experience-injection.d.ts +34 -0
- package/dist/src/pil/session-experience-injection.js +54 -0
- package/dist/src/pil/session-experience-injection.test.d.ts +6 -0
- package/dist/src/pil/session-experience-injection.test.js +79 -0
- package/dist/src/storage/interaction-log.d.ts +1 -1
- package/dist/src/storage/interaction-log.js +17 -4
- package/dist/src/storage/session-experience-store.d.ts +63 -0
- package/dist/src/storage/session-experience-store.js +164 -0
- package/dist/src/storage/session-experience-store.test.d.ts +5 -0
- package/dist/src/storage/session-experience-store.test.js +86 -0
- package/dist/src/storage/tool-results.js +23 -0
- package/dist/src/storage/tool-results.test.d.ts +1 -0
- package/dist/src/storage/tool-results.test.js +48 -0
- package/dist/src/storage/ui-interaction-log.js +4 -2
- package/dist/src/tools/registry-ee-query.test.js +7 -1
- package/dist/src/tools/registry.js +7 -0
- package/dist/src/types/index.d.ts +6 -0
- package/dist/src/ui/__tests__/markdown-render.test.js +17 -0
- package/dist/src/ui/app.js +0 -0
- package/dist/src/ui/markdown-render.js +12 -0
- package/package.json +1 -1
|
@@ -66,6 +66,7 @@ import { getModelInfo } from "../models/registry.js";
|
|
|
66
66
|
import { cheapModelShellLine, injectCheapModelPlaybook, injectCheapModelShellDirective, shouldInjectCheapModelPlaybook, } from "../pil/cheap-model-playbook.js";
|
|
67
67
|
import { injectCheapModelWorkbook, shouldInjectCheapModelWorkbook } from "../pil/cheap-model-workbooks.js";
|
|
68
68
|
import { applyPilSuffix, getResponseTaskType, getResponseToolSet, isResponseTool, runPipeline, shouldHaltOnResponseTool, } from "../pil/index.js";
|
|
69
|
+
import { isMetaAnalysisPrompt } from "../pil/layer6-output.js";
|
|
69
70
|
import { taskTypeToMaxTokens, taskTypeToReasoningEffort, taskTypeToTier } from "../pil/task-tier-map.js";
|
|
70
71
|
import { getProviderCapabilities } from "../providers/capabilities.js";
|
|
71
72
|
import { loadKeyForProvider } from "../providers/keychain.js";
|
|
@@ -78,6 +79,7 @@ import { reportRouteOutcome } from "../router/decide.js";
|
|
|
78
79
|
import { decideStepRouting, getStepRouterConfig } from "../router/step-router.js";
|
|
79
80
|
import { routerStore } from "../router/store.js";
|
|
80
81
|
import { getNextMessageSequence, logInteraction, markMessageErrored, markToolCallErrored, persistMessageWriteAhead, persistToolCallWriteAhead, } from "../storage/index.js";
|
|
82
|
+
import { persistSessionExperience } from "../storage/session-experience-store.js";
|
|
81
83
|
import { createBuiltinTools } from "../tools/registry.js";
|
|
82
84
|
import { snapshotFromTodoWriteArgs } from "../tools/todo-write-snapshot.js";
|
|
83
85
|
import { visionToolsNeeded } from "../tools/vision-gate.js";
|
|
@@ -103,6 +105,7 @@ import { buildRepetitionReminder, recordAssistantBurst, shouldInjectRepetitionRe
|
|
|
103
105
|
import { classifyStreamError } from "./retry-classifier.js";
|
|
104
106
|
import { forcedFinalize, getSessionLastTask, incSessionStep, parseBudgetOverride, recordSessionLastTask, resetSessionStep, resolveCeiling, } from "./scope-ceiling.js";
|
|
105
107
|
import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
|
|
108
|
+
import { formatElisionManifest, getSessionExperienceCounts, recordCompaction, recordElision, } from "./session-experience.js";
|
|
106
109
|
import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
|
|
107
110
|
import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
|
|
108
111
|
import { wrapToolSetWithCap } from "./sub-agent-cap.js";
|
|
@@ -1505,6 +1508,10 @@ export class MessageProcessor {
|
|
|
1505
1508
|
// rehydrate it even if EE is down (the EE extract below caps at 8k
|
|
1506
1509
|
// and needs the network; the cache keeps up to 200k, no network).
|
|
1507
1510
|
recordArtifact(toolCallId, toolName, fullContent);
|
|
1511
|
+
// Lived-experience telemetry: count this elision so a later
|
|
1512
|
+
// "cảm nhận trong CLI" question answers from data, and so the
|
|
1513
|
+
// post-compaction note can list what it just stubbed.
|
|
1514
|
+
recordElision(toolCallId, toolName, fullContent.length, sn);
|
|
1508
1515
|
try {
|
|
1509
1516
|
getDefaultEEClient()
|
|
1510
1517
|
.extract({
|
|
@@ -1527,13 +1534,20 @@ export class MessageProcessor {
|
|
|
1527
1534
|
};
|
|
1528
1535
|
const compacted = compactSubAgentMessages(stripped, {
|
|
1529
1536
|
thresholdChars: topLevelCompactThreshold,
|
|
1530
|
-
|
|
1537
|
+
// Rec #1 (cheap part): on meta/self-eval turns keep a couple more
|
|
1538
|
+
// trailing tool turns verbatim — those carry the reasoning the
|
|
1539
|
+
// agent is being asked to reflect on, and over-eliding them is
|
|
1540
|
+
// exactly what starves a self-evaluation. One boolean, no new
|
|
1541
|
+
// detection logic (isMetaAnalysisPrompt already gates layer3/5).
|
|
1542
|
+
keepLastTurns: topLevelCompactKeepLast + (isMetaAnalysisPrompt(userMessage) ? 2 : 0),
|
|
1531
1543
|
label: "top-level",
|
|
1532
1544
|
envelopeChars,
|
|
1533
1545
|
contextWindowTokens,
|
|
1534
1546
|
keepToolIds: keepToolIds.length ? keepToolIds : undefined,
|
|
1535
1547
|
persistArtifact,
|
|
1536
1548
|
});
|
|
1549
|
+
if (compacted !== stripped)
|
|
1550
|
+
recordCompaction(sn);
|
|
1537
1551
|
// Pre-compaction visibility: give the agent one step of notice
|
|
1538
1552
|
// before B4 actually rewrites history into stubs. This is the
|
|
1539
1553
|
// advance warning that was missing — agent can now decide to
|
|
@@ -1619,7 +1633,15 @@ export class MessageProcessor {
|
|
|
1619
1633
|
// "task finished?", "compacted yet?", "EE checkpoint" so agent can self-assess and avoid mù
|
|
1620
1634
|
// even when the top-level summary is not in its immediate focus (sub-agents, long loops).
|
|
1621
1635
|
const _compactNote = compacted !== stripped
|
|
1622
|
-
?
|
|
1636
|
+
? (() => {
|
|
1637
|
+
// Rec #2: turn the generic "high-value elided? use ee_query"
|
|
1638
|
+
// prose into a concrete, actionable manifest of what was just
|
|
1639
|
+
// stubbed (id/tool/size) — sourced from the elisions recorded
|
|
1640
|
+
// by persistArtifact above — so the rehydrate round-trip is
|
|
1641
|
+
// informed, not blind.
|
|
1642
|
+
const _m = formatElisionManifest();
|
|
1643
|
+
return `[context compacted at step ${sn} — older or low-value tool results rewritten to stubs to fit budget. High-value evidence (file reads, bash, your previous responses) is kept verbatim. ${buildCheckpointReminder(sn, true)}${_m ? ` ${_m}` : ""}]`;
|
|
1644
|
+
})()
|
|
1623
1645
|
: null;
|
|
1624
1646
|
if (_compactNote) {
|
|
1625
1647
|
return { messages: attachReminderToMessages(compacted, _compactNote) };
|
|
@@ -1680,6 +1702,16 @@ export class MessageProcessor {
|
|
|
1680
1702
|
console.error("[Agent:onFinish] failed to emit llm-done", err);
|
|
1681
1703
|
}
|
|
1682
1704
|
deps.setCurrentCallId("");
|
|
1705
|
+
// Rec #1 persisted forensics: onFinish fires once per top-level turn,
|
|
1706
|
+
// so flush this session's cumulative experience counts here. Readers
|
|
1707
|
+
// take the latest row per session, so the last turn's row is the
|
|
1708
|
+
// session total. No-ops on missing id / all-zero. Fail-open.
|
|
1709
|
+
try {
|
|
1710
|
+
persistSessionExperience(deps.session?.id ?? null, getSessionExperienceCounts());
|
|
1711
|
+
}
|
|
1712
|
+
catch (err) {
|
|
1713
|
+
console.error("[Agent:onFinish] persistSessionExperience failed", err);
|
|
1714
|
+
}
|
|
1683
1715
|
},
|
|
1684
1716
|
});
|
|
1685
1717
|
let _topTokenIndex = 0;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/orchestrator/session-experience.ts
|
|
3
|
+
*
|
|
4
|
+
* In-process record of what actually happened to the agent in THIS CLI session:
|
|
5
|
+
* how often compaction fired, which tool outputs were elided, how many of those
|
|
6
|
+
* the agent rehydrated via ee_query (and from where), and whether the Experience
|
|
7
|
+
* Engine misbehaved. It is the single source of truth for the agent's *lived*
|
|
8
|
+
* session experience.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: when a user asks "cảm nhận trong CLI" / "do you feel blind in
|
|
11
|
+
* this session", the agent used to answer by READING the anti-mù source code and
|
|
12
|
+
* theorizing about mechanisms (session ce816796a57d) — friction it never actually
|
|
13
|
+
* observed. That is backwards. With this tracker the agent answers from data —
|
|
14
|
+
* "compaction fired 3x, I rehydrated 2 artifacts, never lost context" — instead of
|
|
15
|
+
* inferring from code. The same counters double as the "measure before you
|
|
16
|
+
* re-architect" instrumentation: how often a real session actually elides a stub
|
|
17
|
+
* the agent then needs.
|
|
18
|
+
*
|
|
19
|
+
* Process-scoped singleton == session-scoped: one CLI invocation is one session.
|
|
20
|
+
* Pure module, no I/O, fully unit-testable; reset hook for tests.
|
|
21
|
+
*/
|
|
22
|
+
export type RehydrateSource = "cache" | "disk" | "ee" | "unavailable";
|
|
23
|
+
export interface ElisionRecord {
|
|
24
|
+
toolCallId: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
/** Full length of the elided output, in chars. */
|
|
27
|
+
chars: number;
|
|
28
|
+
/** prepareStep step number at which it was elided. */
|
|
29
|
+
step: number;
|
|
30
|
+
}
|
|
31
|
+
export interface SessionExperience {
|
|
32
|
+
compactions: number;
|
|
33
|
+
lastCompactionStep: number | null;
|
|
34
|
+
elisions: ReadonlyArray<ElisionRecord>;
|
|
35
|
+
totalElidedChars: number;
|
|
36
|
+
rehydrations: Readonly<Record<RehydrateSource, number>>;
|
|
37
|
+
eeTimeouts: number;
|
|
38
|
+
eeErrors: number;
|
|
39
|
+
}
|
|
40
|
+
/** Record that B3/B4 compaction actually elided something at `step`. */
|
|
41
|
+
export declare function recordCompaction(step: number): void;
|
|
42
|
+
/** Record a single tool output the compactor rewrote into a stub. */
|
|
43
|
+
export declare function recordElision(toolCallId: string, toolName: string, chars: number, step: number): void;
|
|
44
|
+
/**
|
|
45
|
+
* Record an ee_query rehydrate of an elided artifact, tagged by where it came
|
|
46
|
+
* from. `unavailable` means the agent asked for an artifact that was neither in
|
|
47
|
+
* the local cache nor recoverable from EE — the "needed-but-couldn't-get" signal.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordRehydration(source: RehydrateSource): void;
|
|
50
|
+
/** Record an Experience Engine timeout or non-timeout error felt this session. */
|
|
51
|
+
export declare function recordEeEvent(kind: "timeout" | "error"): void;
|
|
52
|
+
/**
|
|
53
|
+
* Flat scalar counts — the shape persisted per session and aggregated
|
|
54
|
+
* cross-session by `usage experience` to decide whether compaction friction is
|
|
55
|
+
* real at a painful rate (no nested arrays, JSON-stable).
|
|
56
|
+
*/
|
|
57
|
+
export interface SessionExperienceCounts {
|
|
58
|
+
compactions: number;
|
|
59
|
+
elided: number;
|
|
60
|
+
totalElidedChars: number;
|
|
61
|
+
rehydratedCache: number;
|
|
62
|
+
rehydratedDisk: number;
|
|
63
|
+
rehydratedEe: number;
|
|
64
|
+
unavailable: number;
|
|
65
|
+
eeTimeouts: number;
|
|
66
|
+
eeErrors: number;
|
|
67
|
+
}
|
|
68
|
+
/** Scalar counts for persistence/aggregation (drops the per-elision array). */
|
|
69
|
+
export declare function getSessionExperienceCounts(): SessionExperienceCounts;
|
|
70
|
+
/** Immutable snapshot of the session so far. */
|
|
71
|
+
export declare function getSessionExperience(): SessionExperience;
|
|
72
|
+
/** Most-recent elisions, newest first — feeds the checkpoint manifest. */
|
|
73
|
+
export declare function recentElisions(n?: number): ElisionRecord[];
|
|
74
|
+
/** True when literally nothing notable has happened yet (context intact). */
|
|
75
|
+
export declare function isSessionExperienceEmpty(): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* A compact manifest of the most-recently elided tool outputs, for the
|
|
78
|
+
* post-compaction checkpoint note: turns the generic "high-value elided? use
|
|
79
|
+
* ee_query" prose into a concrete, actionable list so the agent's rehydrate
|
|
80
|
+
* round-trip is informed rather than blind.
|
|
81
|
+
*/
|
|
82
|
+
export declare function formatElisionManifest(n?: number): string;
|
|
83
|
+
/**
|
|
84
|
+
* The agent-facing felt summary. Injected when the user asks how the agent is
|
|
85
|
+
* doing IN this session, so the answer is grounded in what actually happened —
|
|
86
|
+
* not in a fresh reading of the compaction/PIL source.
|
|
87
|
+
*/
|
|
88
|
+
export declare function formatSessionExperience(): string;
|
|
89
|
+
export declare function __resetSessionExperienceForTests(): void;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/orchestrator/session-experience.ts
|
|
3
|
+
*
|
|
4
|
+
* In-process record of what actually happened to the agent in THIS CLI session:
|
|
5
|
+
* how often compaction fired, which tool outputs were elided, how many of those
|
|
6
|
+
* the agent rehydrated via ee_query (and from where), and whether the Experience
|
|
7
|
+
* Engine misbehaved. It is the single source of truth for the agent's *lived*
|
|
8
|
+
* session experience.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: when a user asks "cảm nhận trong CLI" / "do you feel blind in
|
|
11
|
+
* this session", the agent used to answer by READING the anti-mù source code and
|
|
12
|
+
* theorizing about mechanisms (session ce816796a57d) — friction it never actually
|
|
13
|
+
* observed. That is backwards. With this tracker the agent answers from data —
|
|
14
|
+
* "compaction fired 3x, I rehydrated 2 artifacts, never lost context" — instead of
|
|
15
|
+
* inferring from code. The same counters double as the "measure before you
|
|
16
|
+
* re-architect" instrumentation: how often a real session actually elides a stub
|
|
17
|
+
* the agent then needs.
|
|
18
|
+
*
|
|
19
|
+
* Process-scoped singleton == session-scoped: one CLI invocation is one session.
|
|
20
|
+
* Pure module, no I/O, fully unit-testable; reset hook for tests.
|
|
21
|
+
*/
|
|
22
|
+
/** Bound the elision log so a pathological session can't grow it unbounded. */
|
|
23
|
+
const MAX_ELISIONS = 200;
|
|
24
|
+
function freshState() {
|
|
25
|
+
return {
|
|
26
|
+
compactions: 0,
|
|
27
|
+
lastCompactionStep: null,
|
|
28
|
+
elisions: [],
|
|
29
|
+
rehydrations: { cache: 0, disk: 0, ee: 0, unavailable: 0 },
|
|
30
|
+
eeTimeouts: 0,
|
|
31
|
+
eeErrors: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let state = freshState();
|
|
35
|
+
/** Record that B3/B4 compaction actually elided something at `step`. */
|
|
36
|
+
export function recordCompaction(step) {
|
|
37
|
+
state.compactions += 1;
|
|
38
|
+
state.lastCompactionStep = Number.isFinite(step) ? step : state.lastCompactionStep;
|
|
39
|
+
}
|
|
40
|
+
/** Record a single tool output the compactor rewrote into a stub. */
|
|
41
|
+
export function recordElision(toolCallId, toolName, chars, step) {
|
|
42
|
+
if (!toolCallId)
|
|
43
|
+
return;
|
|
44
|
+
state.elisions.push({
|
|
45
|
+
toolCallId,
|
|
46
|
+
toolName: toolName || "",
|
|
47
|
+
chars: Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0,
|
|
48
|
+
step: Number.isFinite(step) ? step : 0,
|
|
49
|
+
});
|
|
50
|
+
// FIFO trim — keep the most recent MAX_ELISIONS.
|
|
51
|
+
if (state.elisions.length > MAX_ELISIONS) {
|
|
52
|
+
state.elisions.splice(0, state.elisions.length - MAX_ELISIONS);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Record an ee_query rehydrate of an elided artifact, tagged by where it came
|
|
57
|
+
* from. `unavailable` means the agent asked for an artifact that was neither in
|
|
58
|
+
* the local cache nor recoverable from EE — the "needed-but-couldn't-get" signal.
|
|
59
|
+
*/
|
|
60
|
+
export function recordRehydration(source) {
|
|
61
|
+
if (source in state.rehydrations)
|
|
62
|
+
state.rehydrations[source] += 1;
|
|
63
|
+
}
|
|
64
|
+
/** Record an Experience Engine timeout or non-timeout error felt this session. */
|
|
65
|
+
export function recordEeEvent(kind) {
|
|
66
|
+
if (kind === "timeout")
|
|
67
|
+
state.eeTimeouts += 1;
|
|
68
|
+
else
|
|
69
|
+
state.eeErrors += 1;
|
|
70
|
+
}
|
|
71
|
+
/** Scalar counts for persistence/aggregation (drops the per-elision array). */
|
|
72
|
+
export function getSessionExperienceCounts() {
|
|
73
|
+
const s = getSessionExperience();
|
|
74
|
+
return {
|
|
75
|
+
compactions: s.compactions,
|
|
76
|
+
elided: s.elisions.length,
|
|
77
|
+
totalElidedChars: s.totalElidedChars,
|
|
78
|
+
rehydratedCache: s.rehydrations.cache,
|
|
79
|
+
rehydratedDisk: s.rehydrations.disk,
|
|
80
|
+
rehydratedEe: s.rehydrations.ee,
|
|
81
|
+
unavailable: s.rehydrations.unavailable,
|
|
82
|
+
eeTimeouts: s.eeTimeouts,
|
|
83
|
+
eeErrors: s.eeErrors,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Immutable snapshot of the session so far. */
|
|
87
|
+
export function getSessionExperience() {
|
|
88
|
+
return {
|
|
89
|
+
compactions: state.compactions,
|
|
90
|
+
lastCompactionStep: state.lastCompactionStep,
|
|
91
|
+
elisions: state.elisions.slice(),
|
|
92
|
+
totalElidedChars: state.elisions.reduce((sum, e) => sum + e.chars, 0),
|
|
93
|
+
rehydrations: { ...state.rehydrations },
|
|
94
|
+
eeTimeouts: state.eeTimeouts,
|
|
95
|
+
eeErrors: state.eeErrors,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Most-recent elisions, newest first — feeds the checkpoint manifest. */
|
|
99
|
+
export function recentElisions(n = 5) {
|
|
100
|
+
const take = Math.max(0, Math.floor(n));
|
|
101
|
+
return state.elisions.slice(-take).reverse();
|
|
102
|
+
}
|
|
103
|
+
/** True when literally nothing notable has happened yet (context intact). */
|
|
104
|
+
export function isSessionExperienceEmpty() {
|
|
105
|
+
return (state.compactions === 0 &&
|
|
106
|
+
state.elisions.length === 0 &&
|
|
107
|
+
state.eeTimeouts === 0 &&
|
|
108
|
+
state.eeErrors === 0 &&
|
|
109
|
+
state.rehydrations.cache === 0 &&
|
|
110
|
+
state.rehydrations.disk === 0 &&
|
|
111
|
+
state.rehydrations.ee === 0 &&
|
|
112
|
+
state.rehydrations.unavailable === 0);
|
|
113
|
+
}
|
|
114
|
+
function shortId(id) {
|
|
115
|
+
return id.length > 12 ? id.slice(0, 12) : id;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A compact manifest of the most-recently elided tool outputs, for the
|
|
119
|
+
* post-compaction checkpoint note: turns the generic "high-value elided? use
|
|
120
|
+
* ee_query" prose into a concrete, actionable list so the agent's rehydrate
|
|
121
|
+
* round-trip is informed rather than blind.
|
|
122
|
+
*/
|
|
123
|
+
export function formatElisionManifest(n = 5) {
|
|
124
|
+
const recent = recentElisions(n);
|
|
125
|
+
if (recent.length === 0)
|
|
126
|
+
return "";
|
|
127
|
+
const items = recent.map((e) => `id=${shortId(e.toolCallId)} ${e.toolName || "tool"} (${e.chars}c)`).join(" · ");
|
|
128
|
+
return `Elided this turn: ${items}. ee_query "tool-artifact id=XXX" to rehydrate the one you need.`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The agent-facing felt summary. Injected when the user asks how the agent is
|
|
132
|
+
* doing IN this session, so the answer is grounded in what actually happened —
|
|
133
|
+
* not in a fresh reading of the compaction/PIL source.
|
|
134
|
+
*/
|
|
135
|
+
export function formatSessionExperience() {
|
|
136
|
+
const s = getSessionExperience();
|
|
137
|
+
const lines = [];
|
|
138
|
+
lines.push("[session experience — what ACTUALLY happened to you in THIS CLI session so far]");
|
|
139
|
+
if (isSessionExperienceEmpty()) {
|
|
140
|
+
lines.push("- Nothing notable: no compaction, no elision, no EE failures. Your context is intact this session.");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
lines.push(s.compactions === 0
|
|
144
|
+
? "- Compaction: not fired yet — full context retained."
|
|
145
|
+
: `- Compaction: fired ${s.compactions}x${s.lastCompactionStep !== null ? ` (last at step ${s.lastCompactionStep})` : ""}.`);
|
|
146
|
+
if (s.elisions.length === 0) {
|
|
147
|
+
lines.push("- Tool outputs elided: none — nothing was rewritten to a stub.");
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const tools = [...new Set(s.elisions.map((e) => e.toolName || "tool"))].join(", ");
|
|
151
|
+
lines.push(`- Tool outputs elided: ${s.elisions.length} (${s.totalElidedChars} chars; via ${tools}).`);
|
|
152
|
+
}
|
|
153
|
+
const r = s.rehydrations;
|
|
154
|
+
const rehydratedTotal = r.cache + r.disk + r.ee;
|
|
155
|
+
lines.push(rehydratedTotal === 0 && r.unavailable === 0
|
|
156
|
+
? "- Rehydrated via ee_query: none requested."
|
|
157
|
+
: `- Rehydrated via ee_query: cache=${r.cache} disk=${r.disk} ee=${r.ee}; needed-but-unavailable=${r.unavailable}.`);
|
|
158
|
+
if (s.eeTimeouts > 0 || s.eeErrors > 0) {
|
|
159
|
+
lines.push(`- Experience Engine: timeouts=${s.eeTimeouts} errors=${s.eeErrors}.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
lines.push("Answer the user's how-does-it-feel / are-you-blind question FROM THIS lived data — not by reading the CLI source. If everything is zero, say so plainly: nothing degraded your context this session.");
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
// ─── Test hook ─────────────────────────────────────────────────────────────
|
|
166
|
+
export function __resetSessionExperienceForTests() {
|
|
167
|
+
state = freshState();
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=session-experience.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-experience — in-process record of the agent's lived session, so a
|
|
3
|
+
* "cảm nhận trong CLI" / "are you blind?" question is answered from data, not by
|
|
4
|
+
* re-reading source. Also the "measure before re-architecting" instrumentation.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { __resetSessionExperienceForTests, formatElisionManifest, formatSessionExperience, getSessionExperience, isSessionExperienceEmpty, recentElisions, recordCompaction, recordEeEvent, recordElision, recordRehydration, } from "./session-experience.js";
|
|
8
|
+
describe("session-experience tracker", () => {
|
|
9
|
+
afterEach(() => __resetSessionExperienceForTests());
|
|
10
|
+
it("starts empty and reports an intact-context felt summary", () => {
|
|
11
|
+
expect(isSessionExperienceEmpty()).toBe(true);
|
|
12
|
+
const text = formatSessionExperience();
|
|
13
|
+
expect(text).toContain("Nothing notable");
|
|
14
|
+
expect(text).toContain("context is intact this session");
|
|
15
|
+
// Steering line must always tell the agent to use lived data, not source.
|
|
16
|
+
expect(text).toMatch(/not by reading the CLI source/i);
|
|
17
|
+
});
|
|
18
|
+
it("accumulates compaction, elision, rehydration and EE counters", () => {
|
|
19
|
+
recordCompaction(4);
|
|
20
|
+
recordCompaction(9);
|
|
21
|
+
recordElision("call_a", "read_file", 4100, 4);
|
|
22
|
+
recordElision("call_b", "grep", 2300, 9);
|
|
23
|
+
recordRehydration("cache");
|
|
24
|
+
recordRehydration("unavailable");
|
|
25
|
+
recordEeEvent("timeout");
|
|
26
|
+
const s = getSessionExperience();
|
|
27
|
+
expect(s.compactions).toBe(2);
|
|
28
|
+
expect(s.lastCompactionStep).toBe(9);
|
|
29
|
+
expect(s.elisions).toHaveLength(2);
|
|
30
|
+
expect(s.totalElidedChars).toBe(6400);
|
|
31
|
+
expect(s.rehydrations.cache).toBe(1);
|
|
32
|
+
expect(s.rehydrations.unavailable).toBe(1);
|
|
33
|
+
expect(s.eeTimeouts).toBe(1);
|
|
34
|
+
expect(isSessionExperienceEmpty()).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it("felt summary reflects real counters when non-empty", () => {
|
|
37
|
+
recordCompaction(3);
|
|
38
|
+
recordElision("call_x", "read_file", 5000, 3);
|
|
39
|
+
recordRehydration("ee");
|
|
40
|
+
const text = formatSessionExperience();
|
|
41
|
+
expect(text).toContain("fired 1x");
|
|
42
|
+
expect(text).toContain("last at step 3");
|
|
43
|
+
expect(text).toContain("Tool outputs elided: 1");
|
|
44
|
+
expect(text).toContain("ee=1");
|
|
45
|
+
expect(text).not.toContain("Nothing notable");
|
|
46
|
+
});
|
|
47
|
+
it("recentElisions returns newest first and respects the cap arg", () => {
|
|
48
|
+
for (let i = 0; i < 6; i++)
|
|
49
|
+
recordElision(`call_${i}`, "read_file", 1000 + i, i);
|
|
50
|
+
const recent = recentElisions(3);
|
|
51
|
+
expect(recent.map((e) => e.toolCallId)).toEqual(["call_5", "call_4", "call_3"]);
|
|
52
|
+
});
|
|
53
|
+
it("formatElisionManifest is empty with no elisions, actionable otherwise", () => {
|
|
54
|
+
expect(formatElisionManifest()).toBe("");
|
|
55
|
+
recordElision("0123456789abcdefXYZ", "read_file", 4096, 7);
|
|
56
|
+
const m = formatElisionManifest();
|
|
57
|
+
// id is shortened, tool + char count present, and points at ee_query.
|
|
58
|
+
expect(m).toContain("id=0123456789ab");
|
|
59
|
+
expect(m).toContain("read_file (4096c)");
|
|
60
|
+
expect(m).toMatch(/ee_query "tool-artifact id=XXX"/);
|
|
61
|
+
});
|
|
62
|
+
it("caps the elision log at 200 (FIFO) without unbounded growth", () => {
|
|
63
|
+
for (let i = 0; i < 250; i++)
|
|
64
|
+
recordElision(`c_${i}`, "bash", 500, i);
|
|
65
|
+
const s = getSessionExperience();
|
|
66
|
+
expect(s.elisions).toHaveLength(200);
|
|
67
|
+
// Oldest 50 dropped; newest retained.
|
|
68
|
+
expect(s.elisions[0].toolCallId).toBe("c_50");
|
|
69
|
+
expect(s.elisions.at(-1).toolCallId).toBe("c_249");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=session-experience.test.js.map
|
|
@@ -53,6 +53,7 @@ import { repairToolCallHook } from "./repair-tool-call.js";
|
|
|
53
53
|
import { classifyStreamError } from "./retry-classifier.js";
|
|
54
54
|
import { incSessionStep, resolveCeiling } from "./scope-ceiling.js";
|
|
55
55
|
import { attachReminderToMessages, buildScopeReminder, cadenceForSize, shouldInjectReminder, shouldInjectSoftWarn, } from "./scope-reminder.js";
|
|
56
|
+
import { recordCompaction, recordElision } from "./session-experience.js";
|
|
56
57
|
import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
|
|
57
58
|
import { wrapToolSetWithCap } from "./sub-agent-cap.js";
|
|
58
59
|
import { compactSubAgentMessages } from "./subagent-compactor.js";
|
|
@@ -415,6 +416,7 @@ export class StreamRunner {
|
|
|
415
416
|
const persistSubArtifact = (toolCallId, toolName, fullContent, reason) => {
|
|
416
417
|
// Local-first durable cache so ee_query rehydrates even when EE is down.
|
|
417
418
|
recordArtifact(toolCallId, toolName, fullContent);
|
|
419
|
+
recordElision(toolCallId, toolName, fullContent.length, stepNumber);
|
|
418
420
|
try {
|
|
419
421
|
getDefaultEEClient()
|
|
420
422
|
.extract({
|
|
@@ -435,6 +437,8 @@ export class StreamRunner {
|
|
|
435
437
|
keepToolIds: subKeepToolIds.length ? subKeepToolIds : undefined,
|
|
436
438
|
persistArtifact: persistSubArtifact,
|
|
437
439
|
});
|
|
440
|
+
if (compacted !== stripped)
|
|
441
|
+
recordCompaction(stepNumber);
|
|
438
442
|
// Phase 4A — scope reminder injection for the sub-agent loop.
|
|
439
443
|
// Mirror of the top-level wiring in message-processor.ts:
|
|
440
444
|
// K = cadenceForSize(size) where size defaults to "medium" because
|
|
@@ -111,6 +111,16 @@ export declare const SUBAGENT_COMPACT_DEFAULT_KEEP_LAST = 3;
|
|
|
111
111
|
* the native contract + native-capabilities tell the agent to rely on for "task finished?" and
|
|
112
112
|
* rehydrate during long meta conversations about CLI/PIL/compaction/EE. */
|
|
113
113
|
export declare const IMPORTANT_TOOL_NAMES: readonly ["read_file", "grep", "lsp", "bash", "ee_query", "usage_forensics", "selfverify_start", "selfverify_result", "selfverify_status"];
|
|
114
|
+
/**
|
|
115
|
+
* MCP tool prefixes whose results are an AUTHORITATIVE source the agent is
|
|
116
|
+
* explicitly steered to fetch FIRST and ground on (the ECOSYSTEM_DOCS_NUDGE in
|
|
117
|
+
* src/gsd/directives.ts). Eliding them defeats the nudge — the agent calls the
|
|
118
|
+
* ecosystem docs, then compaction discards them and it goes blind on the very
|
|
119
|
+
* source it was told to trust (session 584ba476c07a: mcp_muonroi-docs__setup_guide
|
|
120
|
+
* + bb_recipe_list elided, ee_unavailable, 0 rehydrated → "partially blind").
|
|
121
|
+
* Keep their results verbatim so the agent stays grounded across the session.
|
|
122
|
+
*/
|
|
123
|
+
export declare const HIGH_VALUE_MCP_PREFIXES: readonly ["mcp_muonroi-docs__"];
|
|
114
124
|
/**
|
|
115
125
|
* Heuristic: keep full (no stub) for high-signal tool results.
|
|
116
126
|
* Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
|
|
@@ -73,6 +73,16 @@ export const IMPORTANT_TOOL_NAMES = [
|
|
|
73
73
|
"selfverify_result",
|
|
74
74
|
"selfverify_status",
|
|
75
75
|
];
|
|
76
|
+
/**
|
|
77
|
+
* MCP tool prefixes whose results are an AUTHORITATIVE source the agent is
|
|
78
|
+
* explicitly steered to fetch FIRST and ground on (the ECOSYSTEM_DOCS_NUDGE in
|
|
79
|
+
* src/gsd/directives.ts). Eliding them defeats the nudge — the agent calls the
|
|
80
|
+
* ecosystem docs, then compaction discards them and it goes blind on the very
|
|
81
|
+
* source it was told to trust (session 584ba476c07a: mcp_muonroi-docs__setup_guide
|
|
82
|
+
* + bb_recipe_list elided, ee_unavailable, 0 rehydrated → "partially blind").
|
|
83
|
+
* Keep their results verbatim so the agent stays grounded across the session.
|
|
84
|
+
*/
|
|
85
|
+
export const HIGH_VALUE_MCP_PREFIXES = ["mcp_muonroi-docs__"];
|
|
76
86
|
/**
|
|
77
87
|
* Heuristic: keep full (no stub) for high-signal tool results.
|
|
78
88
|
* Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
|
|
@@ -86,6 +96,10 @@ export function isHighValueToolResult(toolName, preview, explicitKeepIds, toolCa
|
|
|
86
96
|
// work/findings. Truncating it causes the agent to think it lost its answer.
|
|
87
97
|
if (name.startsWith("respond_"))
|
|
88
98
|
return true;
|
|
99
|
+
// Authoritative ecosystem-docs MCP results: the agent is nudged to fetch these
|
|
100
|
+
// FIRST, so eliding them strands it (session 584ba476c07a). Keep verbatim.
|
|
101
|
+
if (HIGH_VALUE_MCP_PREFIXES.some((p) => name.startsWith(p)))
|
|
102
|
+
return true;
|
|
89
103
|
if (IMPORTANT_TOOL_NAMES.includes(name)) {
|
|
90
104
|
const p = preview.toLowerCase();
|
|
91
105
|
if (/error|fail|todo|plan|done|✔|blocked|critical/.test(p))
|
|
@@ -110,6 +110,60 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
|
|
|
110
110
|
expect(out[out.length - i]).toBe(msgs[msgs.length - i]);
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
+
it("keeps an OLDER authoritative muonroi-docs MCP result verbatim while eliding low-value peers (session 584ba476c07a)", () => {
|
|
114
|
+
// History: an early muonroi-docs setup_guide (older than keepLast=3) + many
|
|
115
|
+
// low-value tool turns. The ecosystem doc must survive compaction so the
|
|
116
|
+
// agent stays grounded on the source it was nudged to fetch first.
|
|
117
|
+
const docsValue = bigText("ECOSYSTEM_DOCS", 6); // ~6kb authoritative payload
|
|
118
|
+
const msgs = [
|
|
119
|
+
{ role: "system", content: "You are the agent." },
|
|
120
|
+
{ role: "user", content: "ecosystem question" },
|
|
121
|
+
{
|
|
122
|
+
role: "assistant",
|
|
123
|
+
content: [
|
|
124
|
+
{
|
|
125
|
+
type: "tool-call",
|
|
126
|
+
toolCallId: "call_docs",
|
|
127
|
+
toolName: "mcp_muonroi-docs__setup_guide",
|
|
128
|
+
input: JSON.stringify({ component: "ecosystem" }),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
role: "tool",
|
|
134
|
+
content: [
|
|
135
|
+
{
|
|
136
|
+
type: "tool-result",
|
|
137
|
+
toolCallId: "call_docs",
|
|
138
|
+
toolName: "mcp_muonroi-docs__setup_guide",
|
|
139
|
+
output: { type: "text", value: docsValue },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
// Pile on low-value turns to push well past threshold and make the docs turn "old".
|
|
145
|
+
for (let i = 1; i <= 10; i++) {
|
|
146
|
+
const t = toolTurn(i, 10);
|
|
147
|
+
t[1].content[0].toolName = "mcp_filesystem__list_directory"; // low-value MCP
|
|
148
|
+
t[0].content[0].toolName = "mcp_filesystem__list_directory";
|
|
149
|
+
msgs.push(...t);
|
|
150
|
+
}
|
|
151
|
+
const out = compactSubAgentMessages(msgs);
|
|
152
|
+
expect(out).not.toBe(msgs); // compaction fired
|
|
153
|
+
// The muonroi-docs result is kept verbatim (full payload, no stub).
|
|
154
|
+
const docsMsg = out.find((m) => m.role === "tool" &&
|
|
155
|
+
Array.isArray(m.content) &&
|
|
156
|
+
m.content[0]?.toolName === "mcp_muonroi-docs__setup_guide");
|
|
157
|
+
const docsOut = (docsMsg?.content)[0].output.value;
|
|
158
|
+
expect(docsOut).toBe(docsValue);
|
|
159
|
+
expect(docsOut).not.toMatch(/elided by/);
|
|
160
|
+
// A low-value filesystem MCP peer from an OLD turn IS stubbed.
|
|
161
|
+
const stubbed = out.some((m) => m.role === "tool" &&
|
|
162
|
+
Array.isArray(m.content) &&
|
|
163
|
+
typeof m.content[0]?.output?.value === "string" &&
|
|
164
|
+
m.content[0].output.value.includes("elided by"));
|
|
165
|
+
expect(stubbed).toBe(true);
|
|
166
|
+
});
|
|
113
167
|
it("rewrites older tool-result parts with elision stub", () => {
|
|
114
168
|
const msgs = buildHistory(10, 10);
|
|
115
169
|
// Neutralize tool so the basic elision test is not affected by high-value auto-keep (idea 1).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
-
import { layer3EeInjection } from "../layer3-ee-injection.js";
|
|
2
|
+
import { layer3EeInjection, RECALL_FEEDBACK_NUDGE } from "../layer3-ee-injection.js";
|
|
3
3
|
vi.mock("../../ee/bridge.js", () => ({
|
|
4
4
|
searchByText: vi.fn().mockResolvedValue([]),
|
|
5
5
|
}));
|
|
@@ -93,8 +93,10 @@ describe("layer3EeInjection (bridge-based)", () => {
|
|
|
93
93
|
const chars = parseInt(charsMatch[1], 10);
|
|
94
94
|
// Two parallel collections, each at 15% of budget: 15% of 100 tokens * 4 chars/token
|
|
95
95
|
// = 60 chars per block + 3 for "..." suffix, joined with newline. Allow generous
|
|
96
|
-
// ceiling for header text + 2 blocks
|
|
97
|
-
|
|
96
|
+
// ceiling for header text + 2 blocks, PLUS the fixed ee_feedback nudge appended
|
|
97
|
+
// when rateable experience is present. The 2000-char input means a truncation
|
|
98
|
+
// regression would blow well past this bound regardless.
|
|
99
|
+
expect(chars).toBeLessThanOrEqual(260 + RECALL_FEEDBACK_NUDGE.length + 1);
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
});
|
|
@@ -75,6 +75,37 @@ describe("layer3 experience_injected chunk emission (CQ-16b)", () => {
|
|
|
75
75
|
expect(chunk?.experienceInjected?.scoreFloor).toBeDefined();
|
|
76
76
|
expect(typeof chunk?.experienceInjected?.scoreFloor).toBe("number");
|
|
77
77
|
});
|
|
78
|
+
it("experience_injected chunk carries per-point {id, title, tier} so the TUI can show WHAT was injected", async () => {
|
|
79
|
+
mockSearchByText.mockResolvedValue([
|
|
80
|
+
{
|
|
81
|
+
id: "point-1",
|
|
82
|
+
score: 0.9,
|
|
83
|
+
payload: { text: "Use dependency injection for testability" },
|
|
84
|
+
collection: "experience-behavioral",
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
await layer3EeInjection(BASE_CTX);
|
|
88
|
+
const chunk = capturedSinkCalls.find((c) => typeof c !== "string" && c.type === "experience_injected");
|
|
89
|
+
const points = chunk?.experienceInjected?.points;
|
|
90
|
+
expect(Array.isArray(points)).toBe(true);
|
|
91
|
+
expect(points.length).toBeGreaterThan(0);
|
|
92
|
+
const p = points[0];
|
|
93
|
+
expect(p.id).toBe("point-1");
|
|
94
|
+
expect(p.title).toContain("dependency injection");
|
|
95
|
+
expect(["principle", "behavioral", "checkpoint"]).toContain(p.tier);
|
|
96
|
+
});
|
|
97
|
+
it("appends an ee_feedback nudge to the injected text when rateable experience is present", async () => {
|
|
98
|
+
mockSearchByText.mockResolvedValue([
|
|
99
|
+
{
|
|
100
|
+
id: "p1",
|
|
101
|
+
score: 0.9,
|
|
102
|
+
payload: { text: "Prefer composition over inheritance" },
|
|
103
|
+
collection: "experience-behavioral",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
const result = await layer3EeInjection(BASE_CTX);
|
|
107
|
+
expect(result.enriched).toContain("ee_feedback(id, followed|ignored|noise)");
|
|
108
|
+
});
|
|
78
109
|
it("does NOT emit experience_injected when searchByText returns empty array", async () => {
|
|
79
110
|
mockSearchByText.mockResolvedValue([]);
|
|
80
111
|
await layer3EeInjection(BASE_CTX);
|
|
@@ -65,6 +65,23 @@ describe("runPipeline()", () => {
|
|
|
65
65
|
expect(ctx.layers[i].delta).not.toBe("skipped:null-taskType");
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
+
it("felt-experience prompt injects the session snapshot even when taskType is null", async () => {
|
|
69
|
+
// Regression: the felt-experience injection was first placed INSIDE the
|
|
70
|
+
// `taskType !== null` branch, so a "cảm nhận trong CLI" question that
|
|
71
|
+
// classifies to null (not a coding task) silently skipped it. It must run
|
|
72
|
+
// regardless of taskType.
|
|
73
|
+
mockClassify.mockReturnValue({ tier: "abstain", confidence: 0.2, reason: "low-confidence" });
|
|
74
|
+
const ctx = await runPipeline("bạn có bị mù context không trong session này, cảm nhận thế nào");
|
|
75
|
+
expect(ctx.taskType).toBeNull();
|
|
76
|
+
expect(ctx.layers.find((l) => l.name === "session-experience")?.applied).toBe(true);
|
|
77
|
+
expect(ctx.enriched).toContain("[session experience —");
|
|
78
|
+
expect(ctx.enriched).toMatch(/not by reading the CLI source/i);
|
|
79
|
+
});
|
|
80
|
+
it("plain evaluate-the-CLI prompt does NOT inject the session snapshot", async () => {
|
|
81
|
+
const ctx = await runPipeline("đánh giá agent bên trong cli và đề xuất cải thiện");
|
|
82
|
+
expect(ctx.layers.find((l) => l.name === "session-experience")).toBeUndefined();
|
|
83
|
+
expect(ctx.enriched).not.toContain("[session experience —");
|
|
84
|
+
});
|
|
68
85
|
it("metrics.totalMs is a non-negative number", async () => {
|
|
69
86
|
const ctx = await runPipeline("refactor this");
|
|
70
87
|
expect(ctx.metrics).not.toBeNull();
|