@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20
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/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +1 -4
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +64 -250
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +30 -39
- package/akanConfig/akanConfig.ts +40 -136
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.ts +25 -59
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +3 -19
- package/artifact/implicitRootLayout.ts +34 -91
- package/artifact/routeSeedIndex.ts +11 -44
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +4 -14
- package/capacitorApp.ts +86 -235
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +33 -57
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +3 -2
- package/cloud/globalConfig.ts +64 -31
- package/codeAgent/agent/AkanCodeServices.ts +15 -57
- package/codeAgent/agent/AkanContextFiles.ts +5 -28
- package/codeAgent/agent/CodeAgent.ts +33 -108
- package/codeAgent/agent/CodeAgentAsks.ts +7 -30
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
- package/codeAgent/agent/CodeAgentGate.ts +8 -34
- package/codeAgent/agent/CodeAgentProxy.ts +4 -13
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
- package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
- package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
- package/codeAgent/agent/CodeAgentUi.ts +2 -7
- package/codeAgent/agent/CodeMailbox.ts +7 -34
- package/codeAgent/agent/CodeSessionFork.ts +4 -19
- package/codeAgent/agent/CodeSessionIndex.ts +5 -31
- package/codeAgent/agent/SubagentPool.ts +48 -58
- package/codeAgent/agent/akanCodeModel.ts +5 -39
- package/codeAgent/agent/akanCodePaths.ts +4 -24
- package/codeAgent/agent/akanContextFiles.test.ts +0 -2
- package/codeAgent/agent/akanSystemPrompt.ts +1 -9
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +5 -28
- package/codeAgent/agent/codeMailbox.test.ts +0 -4
- package/codeAgent/agent/codeSessionFork.test.ts +0 -2
- package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
- package/codeAgent/agent/consoleToStderr.ts +2 -12
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +3 -18
- package/codeAgent/feedback/PreviewChrome.ts +5 -29
- package/codeAgent/feedback/PreviewFeedback.ts +2 -9
- package/codeAgent/feedback/PreviewView.ts +3 -15
- package/codeAgent/feedback/TurnFeedback.ts +2 -17
- package/codeAgent/feedback/VerifyFeedback.ts +1 -7
- package/codeAgent/feedback/previewChrome.test.ts +1 -1
- package/codeAgent/index.ts +2 -10
- package/codeAgent/skills/akan-code/SKILL.md +12 -4
- package/codeAgent/tools/AkanCli.ts +4 -16
- package/codeAgent/tools/AkanCodePlugins.ts +21 -43
- package/codeAgent/tools/AkanEditScope.ts +2 -12
- package/codeAgent/tools/AkanEnvKeys.ts +3 -11
- package/codeAgent/tools/AkanToolPack.ts +6 -28
- package/codeAgent/tools/AkanVerifier.ts +1 -8
- package/codeAgent/tools/AskToolPack.ts +3 -15
- package/codeAgent/tools/MailToolPack.ts +0 -9
- package/codeAgent/tools/McpClient.ts +3 -24
- package/codeAgent/tools/McpOAuth.ts +6 -41
- package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
- package/codeAgent/tools/McpServerConfig.ts +7 -26
- package/codeAgent/tools/McpSignIn.ts +46 -49
- package/codeAgent/tools/McpTokenStore.ts +6 -17
- package/codeAgent/tools/McpToolPack.ts +107 -35
- package/codeAgent/tools/SessionToolPack.ts +1 -11
- package/codeAgent/tools/WebToolPack.ts +1 -7
- package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
- package/codeAgent/tools/askToolPack.test.ts +0 -3
- package/codeAgent/tools/mcpOAuth.test.ts +315 -27
- package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +44 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +1 -9
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +66 -144
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +27 -78
- package/devkitUtils.test.ts +47 -19
- package/executors.test.ts +114 -60
- package/executors.ts +165 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +1 -1
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +14 -38
- package/frontendBuild/autoImportSync.ts +15 -67
- package/frontendBuild/buildRouteClient.test.ts +10 -36
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +5 -18
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +5 -13
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +27 -52
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +7 -31
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +7 -47
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +14 -18
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
- package/index.ts +3 -15
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +9 -29
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/gritRules.test.ts +6 -23
- package/linter.test.ts +1 -2
- package/linter.ts +22 -115
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +2 -2
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +3 -14
- package/platformTest/IdleWatchedProcess.test.ts +5 -13
- package/platformTest/IdleWatchedProcess.ts +1 -5
- package/platformTest/LinuxDockerTarget.ts +20 -35
- package/platformTest/PlatformTestRun.test.ts +5 -9
- package/platformTest/PlatformTestTarget.ts +24 -2
- package/platformTest/TestSnapshot.test.ts +6 -14
- package/platformTest/TestSnapshot.ts +1 -5
- package/platformTest/WindowsSshTarget.ts +6 -22
- package/prompter.ts +3 -6
- package/qualityScanner.test.ts +6 -24
- package/qualityScanner.ts +196 -284
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.ts +7 -30
- package/scanInfo.ts +82 -214
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/subspace.test.ts +6 -25
- package/subspace.ts +31 -109
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +49 -34
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +85 -146
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/source.ts +33 -56
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -8,42 +8,29 @@ export interface SubagentPoolOptions {
|
|
|
8
8
|
cwd: string;
|
|
9
9
|
parent: CodeAgentProfile;
|
|
10
10
|
depth: number;
|
|
11
|
-
/**
|
|
11
|
+
/** What the tree above this pool has spent and has running; a root pool starts its own. */
|
|
12
|
+
spend?: SubagentSpend;
|
|
12
13
|
onNotice?: (message: string) => void;
|
|
13
14
|
/** The running children, whole, for a host that draws them as a rail beside the prompt. */
|
|
14
15
|
onAgents?: (agents: CodeAgentSubagent[]) => void;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** One object shared down a sub-agent tree, so a single token budget and a single concurrency limit bound it. */
|
|
19
|
+
export interface SubagentSpend {
|
|
20
|
+
tokens: number;
|
|
21
|
+
running: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const maxResultChars = 8_000;
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
* How often the running children are re-reported while none of them starts or finishes.
|
|
21
|
-
*
|
|
22
|
-
* A child spends tokens for minutes between the two frames its lifetime would otherwise produce, and the
|
|
23
|
-
* number that decides whether to let it keep going is the one climbing in between. One frame a second for at
|
|
24
|
-
* most a handful of rows is far below what a single streaming turn already puts on the wire.
|
|
25
|
-
*/
|
|
26
|
+
// Children are re-reported every second: their token counts climb between start and finish.
|
|
26
27
|
const tickMs = 1_000;
|
|
27
28
|
|
|
28
29
|
export type SubagentKind = keyof typeof SubagentPool.kinds;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
* **Only the summary crosses back.** A subagent's whole transcript in the parent's context is the fastest way
|
|
34
|
-
* to spend a window, and the reason to spawn one is precisely that its reading should not become the parent's
|
|
35
|
-
* reading.
|
|
36
|
-
*
|
|
37
|
-
* **The budget is not optional.** Depth, concurrency and token ceilings are checked before a child exists,
|
|
38
|
-
* because a recursive `task` with no ceiling is a fork bomb that bills per token.
|
|
39
|
-
*/
|
|
31
|
+
// Only a child's answer crosses back, never its transcript. Depth, concurrency and token ceilings are checked
|
|
32
|
+
// before a child exists, because an unbounded recursive `task` is a fork bomb that bills per token.
|
|
40
33
|
export class SubagentPool {
|
|
41
|
-
/**
|
|
42
|
-
* What a sub-agent is allowed to be, and the only axis that matters: whether it can change the tree.
|
|
43
|
-
*
|
|
44
|
-
* A reader can be spawned for anything — the worst it costs is tokens. A writer works unwatched on the same
|
|
45
|
-
* checkout as its parent, so it is the one the parent has to ask for by name rather than get by default.
|
|
46
|
-
*/
|
|
47
34
|
static readonly kinds = {
|
|
48
35
|
explore: {
|
|
49
36
|
readOnly: true,
|
|
@@ -55,20 +42,20 @@ export class SubagentPool {
|
|
|
55
42
|
},
|
|
56
43
|
} as const;
|
|
57
44
|
|
|
45
|
+
// A writer works unwatched on the parent's checkout, so it has to be asked for by name, never defaulted.
|
|
58
46
|
static readonly defaultKind: SubagentKind = "explore";
|
|
59
47
|
|
|
60
48
|
readonly #options: SubagentPoolOptions;
|
|
61
|
-
/** Keyed by the `task` call that opened each child. `agent` lands once it exists, and reports live tokens. */
|
|
62
49
|
readonly #children = new Map<
|
|
63
50
|
string,
|
|
64
51
|
{ kind: SubagentKind; description: string; startedAt: number; agent?: { tokensUsed: number } }
|
|
65
52
|
>();
|
|
66
|
-
#
|
|
67
|
-
#spent = 0;
|
|
53
|
+
readonly #spend: SubagentSpend;
|
|
68
54
|
#ticker: ReturnType<typeof setInterval> | null = null;
|
|
69
55
|
|
|
70
56
|
constructor(options: SubagentPoolOptions) {
|
|
71
57
|
this.#options = options;
|
|
58
|
+
this.#spend = options.spend ?? { tokens: 0, running: 0 };
|
|
72
59
|
}
|
|
73
60
|
|
|
74
61
|
static extensionFor(options: SubagentPoolOptions): InlineExtension | undefined {
|
|
@@ -103,17 +90,16 @@ export class SubagentPool {
|
|
|
103
90
|
const kind = (params.type ?? SubagentPool.defaultKind) as SubagentKind;
|
|
104
91
|
const refusal = this.#refuse();
|
|
105
92
|
if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
|
|
106
|
-
this.#running += 1;
|
|
93
|
+
this.#spend.running += 1;
|
|
107
94
|
this.#children.set(id, { kind, description: params.description, startedAt: Date.now() });
|
|
108
95
|
this.#report();
|
|
109
|
-
//
|
|
110
|
-
// reads the repo is otherwise indistinguishable from a turn that has stopped responding.
|
|
96
|
+
// Announced at start: a turn quiet for a minute while a child reads looks like one that stopped responding.
|
|
111
97
|
this.#options.onNotice?.(`${kind} sub-agent · ${params.description}`);
|
|
112
98
|
try {
|
|
113
99
|
const text = await this.#run(id, kind, params.prompt, signal);
|
|
114
100
|
return { content: [{ type: "text", text }], details: undefined };
|
|
115
101
|
} finally {
|
|
116
|
-
this.#running -= 1;
|
|
102
|
+
this.#spend.running -= 1;
|
|
117
103
|
this.#children.delete(id);
|
|
118
104
|
this.#report();
|
|
119
105
|
}
|
|
@@ -124,20 +110,15 @@ export class SubagentPool {
|
|
|
124
110
|
#refuse() {
|
|
125
111
|
const budget = this.#options.parent.tools.subagent;
|
|
126
112
|
if (!budget) return "Sub-agents are disabled by this profile.";
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
113
|
+
// Refused, never queued: every ancestor keeps its slot while it waits on its child, so a queued child deadlocks.
|
|
114
|
+
if (this.#spend.running >= budget.maxConcurrent)
|
|
115
|
+
return `Too many sub-agents are already running (limit ${budget.maxConcurrent} at once, counted across the whole task tree). ${this.#children.size ? "Wait for one of yours to finish, or do" : "Do"} this part yourself.`;
|
|
116
|
+
if (this.#spend.tokens >= budget.budget)
|
|
130
117
|
return `The sub-agent token budget (${budget.budget}) is spent. Do the rest of this work yourself.`;
|
|
131
118
|
return undefined;
|
|
132
119
|
}
|
|
133
120
|
|
|
134
|
-
/**
|
|
135
|
-
* The child is the parent profile **narrowed** and never widened: in-memory sessions so nothing it does
|
|
136
|
-
* shows up in the workspace's session tree, no `AGENTS.md` — a sub-agent is asked one narrow question and
|
|
137
|
-
* the guide is 26k tokens of its budget — and no ability to ask a human, there being nobody on the other
|
|
138
|
-
* end of a nested turn. The skills stay: they are descriptions until one is needed, and the child writes
|
|
139
|
-
* code too.
|
|
140
|
-
*/
|
|
121
|
+
/** The parent profile narrowed, never widened: no session file, no `AGENTS.md`, nobody to ask; skills stay. */
|
|
141
122
|
static childOf(parent: CodeAgentProfile, kind: SubagentKind): CodeAgentProfile {
|
|
142
123
|
const budget = parent.tools.subagent;
|
|
143
124
|
return {
|
|
@@ -145,8 +126,7 @@ export class SubagentPool {
|
|
|
145
126
|
name: `${parent.name}:${kind}`,
|
|
146
127
|
tools: {
|
|
147
128
|
...parent.tools,
|
|
148
|
-
// Filtered
|
|
149
|
-
// a read-only session must not become the one thing in the tree that can write.
|
|
129
|
+
// Filtered, not replaced: a child of a read-only session must never gain a write tool.
|
|
150
130
|
builtin: SubagentPool.kinds[kind].readOnly
|
|
151
131
|
? parent.tools.builtin.filter((tool) => codeAgentReadOnlyBuiltins.includes(tool))
|
|
152
132
|
: parent.tools.builtin,
|
|
@@ -158,13 +138,7 @@ export class SubagentPool {
|
|
|
158
138
|
};
|
|
159
139
|
}
|
|
160
140
|
|
|
161
|
-
|
|
162
|
-
* The running children as a list, and the ticker that keeps it moving.
|
|
163
|
-
*
|
|
164
|
-
* The ticker is owned by the report rather than by the tool call: two children overlapping must not each
|
|
165
|
-
* start one, and the last one to finish is what stops it. It is unref'd because a pool with a child still
|
|
166
|
-
* registered is a bug that should end the process, not hold it open.
|
|
167
|
-
*/
|
|
141
|
+
// One ticker per pool, unref'd: a child left registered is a bug that must not hold the process open.
|
|
168
142
|
#report() {
|
|
169
143
|
const onAgents = this.#options.onAgents;
|
|
170
144
|
if (!onAgents) return;
|
|
@@ -196,28 +170,44 @@ export class SubagentPool {
|
|
|
196
170
|
profile: child,
|
|
197
171
|
apps: [],
|
|
198
172
|
depth: this.#options.depth + 1,
|
|
173
|
+
subagentSpend: this.#spend,
|
|
199
174
|
});
|
|
200
175
|
const running = this.#children.get(id);
|
|
201
176
|
if (running) running.agent = agent;
|
|
202
|
-
|
|
177
|
+
let stopped = false;
|
|
178
|
+
const abort = () => {
|
|
179
|
+
stopped = true;
|
|
180
|
+
void agent.abort();
|
|
181
|
+
};
|
|
203
182
|
signal?.addEventListener("abort", abort, { once: true });
|
|
183
|
+
// A listener added to an already-aborted signal never fires: this child was stopped while it was being created.
|
|
184
|
+
if (signal?.aborted) stopped = true;
|
|
185
|
+
let finished = false;
|
|
204
186
|
try {
|
|
205
187
|
let answer = "";
|
|
206
188
|
agent.on((event) => {
|
|
207
189
|
if (event.type === "message" && event.role === "assistant") answer = event.text;
|
|
190
|
+
// The engine drops an abort that lands before its run is live, so a stopped child is stopped again there.
|
|
191
|
+
if (event.type === "turn_start" && stopped) void agent.abort();
|
|
208
192
|
});
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
`${kind} sub-agent finished, ${agent.tokensUsed} tokens (${this.#spent} of ${ceiling} spent)`,
|
|
215
|
-
);
|
|
193
|
+
if (!stopped) {
|
|
194
|
+
await agent.prompt(prompt);
|
|
195
|
+
await agent.waitForIdle();
|
|
196
|
+
}
|
|
197
|
+
finished = true;
|
|
216
198
|
return answer.length > maxResultChars
|
|
217
199
|
? `${answer.slice(0, maxResultChars)}\n…(truncated)`
|
|
218
200
|
: answer || "(no answer)";
|
|
219
201
|
} finally {
|
|
220
202
|
signal?.removeEventListener("abort", abort);
|
|
203
|
+
// Whatever the outcome: a failed or stopped child was billed too, and its own children are counted by its pool.
|
|
204
|
+
const tokens = agent.tokensUsed;
|
|
205
|
+
this.#spend.tokens += tokens;
|
|
206
|
+
const outcome = stopped ? "stopped" : finished ? "finished" : "failed";
|
|
207
|
+
const ceiling = budget ? budget.budget : 0;
|
|
208
|
+
this.#options.onNotice?.(
|
|
209
|
+
`${kind} sub-agent ${outcome}, ${tokens} tokens (${this.#spend.tokens} of ${ceiling} spent)`,
|
|
210
|
+
);
|
|
221
211
|
agent.dispose();
|
|
222
212
|
}
|
|
223
213
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* The engine's `Model` type lives in `@earendil-works/pi-ai` and is not re-exported from the package we depend
|
|
5
|
-
* on, so it is recovered from the one public function that returns it rather than by adding a dependency on a
|
|
6
|
-
* transitive package whose version we do not control.
|
|
7
|
-
*/
|
|
3
|
+
// The engine's `Model` type (from `pi-ai`) is not re-exported, so it is recovered from `getModel`.
|
|
8
4
|
export type CodeAgentModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
|
|
9
5
|
|
|
10
6
|
export interface CodeAgentModelRef {
|
|
@@ -15,18 +11,8 @@ export interface CodeAgentModelRef {
|
|
|
15
11
|
/** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
|
|
16
12
|
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
* `input` is the catalogue's claim, and a stale one is expensive in one direction only: with `image` missing
|
|
22
|
-
* the `read` tool refuses to hand the model a picture and a paste is refused before it is ever sent, so
|
|
23
|
-
* nothing ever finds out. Measured 2026-09-20 against the provider — `deepseek-v4-flash` answered "A red
|
|
24
|
-
* star." to `apps/akan/public/icon-192x192.png` and "Red" to the colour, over two requests.
|
|
25
|
-
*
|
|
26
|
-
* ⚠️ `deepseek-v4-pro` is **not** on this list. It answers 200 to the same request and then says it cannot
|
|
27
|
-
* view the image, which is not the same evidence, and guessing the family from one member is how the
|
|
28
|
-
* catalogue got it wrong in the first place.
|
|
29
|
-
*/
|
|
14
|
+
// Declared text-only by the catalogue, yet verified against the provider to see images.
|
|
15
|
+
// `deepseek-v4-pro` is deliberately absent: it accepts an image and then says it cannot view it.
|
|
30
16
|
const catalogueMissesImages = new Set(["deepseek/deepseek-v4-flash"]);
|
|
31
17
|
|
|
32
18
|
const correctInputs = (model: CodeAgentModel | undefined) => {
|
|
@@ -35,13 +21,7 @@ const correctInputs = (model: CodeAgentModel | undefined) => {
|
|
|
35
21
|
return { ...model, input: [...model.input, "image" as const] };
|
|
36
22
|
};
|
|
37
23
|
|
|
38
|
-
|
|
39
|
-
* Resolves the model to run with, preferring an explicit request, then the default, then anything the user has
|
|
40
|
-
* credentials for.
|
|
41
|
-
*
|
|
42
|
-
* An empty API key produces a request that succeeds with no content rather than an error, so a provider with no
|
|
43
|
-
* configured auth is skipped here instead of answering with nothing three seconds later.
|
|
44
|
-
*/
|
|
24
|
+
// An empty API key yields a successful empty response rather than an error, so unauthorized providers are skipped.
|
|
45
25
|
export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelRef) => {
|
|
46
26
|
if (ref) {
|
|
47
27
|
const requested = runtime.getModel(ref.provider, ref.id);
|
|
@@ -55,23 +35,9 @@ export const akanCodeModel = async (runtime: ModelRuntime, ref?: CodeAgentModelR
|
|
|
55
35
|
return correctInputs((await runtime.getAvailable())[0]);
|
|
56
36
|
};
|
|
57
37
|
|
|
58
|
-
/** A screenshot is worth attaching only to a model that can see it — after the catalogue has been corrected. */
|
|
59
38
|
export const akanCodeModelSupportsImages = (model: CodeAgentModel | undefined) => !!model?.input.includes("image");
|
|
60
39
|
|
|
61
|
-
|
|
62
|
-
* Says so when the chosen model's window is too small for the compaction policy to leave room for a
|
|
63
|
-
* conversation.
|
|
64
|
-
*
|
|
65
|
-
* This is deliberately the **only** descriptor check. Two more obvious ones are wrong here: `maxTokens >=
|
|
66
|
-
* contextWindow` matches 153 of the engine's own 1,057 catalogue entries (`gpt-4` is declared 8192/8192, and
|
|
67
|
-
* several qwen models 262000/262000), and `reserveTokens > maxTokens` matches 145 — every model with a small
|
|
68
|
-
* output cap, which is normal, because the reserve is context headroom and the cap is output length. Both
|
|
69
|
-
* would cry wolf on one model in seven.
|
|
70
|
-
*
|
|
71
|
-
* ⚠️ **A wrong descriptor that is internally consistent cannot be detected here at all.** A custom model
|
|
72
|
-
* declaring a 65k window for a provider that serves 1M is coherent and simply false, and the only thing that
|
|
73
|
-
* catches it is a person seeing the number — which is why the session line prints it.
|
|
74
|
-
*/
|
|
40
|
+
// Deliberately the only check: `maxTokens >= contextWindow` or `reserveTokens > maxTokens` flag 1 model in 7.
|
|
75
41
|
export const akanCodeModelWarnings = (model: CodeAgentModel | undefined, compactionFloor: number) => {
|
|
76
42
|
if (!model) return [];
|
|
77
43
|
if (model.contextWindow >= compactionFloor) return [];
|
|
@@ -2,30 +2,16 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* Where the shipped akan skills sit, which differs between the source tree and the bundle: the CLI's entry
|
|
7
|
-
* chunks are flattened into the package root, so `skills/` is a sibling of the caller there and one level up
|
|
8
|
-
* here, beside this folder. Same two-candidate shape the guidelines lookup uses.
|
|
9
|
-
*/
|
|
5
|
+
// Two candidates: the CLI bundle flattens entry chunks into the package root, where `skills/` is a sibling.
|
|
10
6
|
const builtinSkillsDir = () => {
|
|
11
7
|
const candidates = [path.join(import.meta.dir, "skills"), path.join(import.meta.dir, "..", "skills")];
|
|
12
8
|
return candidates.find((candidate) => existsSync(candidate));
|
|
13
9
|
};
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
* Where `akan code` keeps what belongs to the person rather than to the repo.
|
|
17
|
-
*
|
|
18
|
-
* `AKAN_CODE_HOME` moves the whole set. A container that mounts no home directory, a CI job that must not
|
|
19
|
-
* write to one, and a second checkout that wants its own credentials all need the same thing, and moving the
|
|
20
|
-
* files apart would let a `models.json` and the `auth.json` it names drift into different directories.
|
|
21
|
-
*/
|
|
11
|
+
// One override for the whole set, so a `models.json` and the `auth.json` it names never drift apart.
|
|
22
12
|
const globalDir = () => process.env.AKAN_CODE_HOME ?? path.join(homedir(), ".akan", "code");
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
* Sessions live in the workspace — they are project history, and a second checkout of the same repo is a
|
|
26
|
-
* different project. Credentials live in the home directory instead: a repo directory is the one place a key
|
|
27
|
-
* must never be, because it is the place that gets committed, zipped and shared.
|
|
28
|
-
*/
|
|
14
|
+
// Credentials live under the home directory, never the repo: a repo is what gets committed, zipped and shared.
|
|
29
15
|
export const akanCodePaths = {
|
|
30
16
|
workspaceDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code"),
|
|
31
17
|
sessionsDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "sessions"),
|
|
@@ -34,13 +20,7 @@ export const akanCodePaths = {
|
|
|
34
20
|
mailDir: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mail"),
|
|
35
21
|
builtinSkillsDir,
|
|
36
22
|
mcpFile: (workspaceRoot: string) => path.join(workspaceRoot, ".akan", "code", "mcp.json"),
|
|
37
|
-
/**
|
|
38
|
-
* The MCP servers that belong to the person rather than to one checkout.
|
|
39
|
-
*
|
|
40
|
-
* A server is an integration with an account — the credential for it is already global, in `mcpAuth.json` —
|
|
41
|
-
* so declaring it per repo means declaring it again in every repo and signing in again in each. The
|
|
42
|
-
* workspace file stays, for a server that is genuinely this repo's, and wins on a name they share.
|
|
43
|
-
*/
|
|
23
|
+
/** Person-wide MCP servers; the workspace `mcpFile` wins on a name they share. */
|
|
44
24
|
globalMcpFile: () => path.join(globalDir(), "mcp.json"),
|
|
45
25
|
globalSkillsDir: () => path.join(globalDir(), "skills"),
|
|
46
26
|
globalDir,
|
|
@@ -17,7 +17,6 @@ const workspace = (files: Record<string, string>) => {
|
|
|
17
17
|
return root;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
/** What the engine hands over: the one context file it took for that directory. */
|
|
21
20
|
const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
|
|
22
21
|
{ path: path.join(root, name), content: "the workspace guide" },
|
|
23
22
|
];
|
|
@@ -60,7 +59,6 @@ describe("workspace context files", () => {
|
|
|
60
59
|
expect(files[1]?.content).toBe("never use any\n");
|
|
61
60
|
});
|
|
62
61
|
|
|
63
|
-
/** Cursor's other three kinds are conditional; applying one always is applying a rule its author scoped. */
|
|
64
62
|
test("a scoped cursor rule is left to cursor", () => {
|
|
65
63
|
const root = workspace({
|
|
66
64
|
".cursor/rules/ui.mdc": "---\nglobs: ui/**/*.tsx\n---\nno inline colours\n",
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { CodeAgentProfile } from "akanjs/common";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* What the agent has to know that `AGENTS.md` does not say.
|
|
5
|
-
*
|
|
6
|
-
* The workspace guide is already loaded whole — 104KB, roughly 26k tokens, on every turn — so this stays short
|
|
7
|
-
* and carries only what is about *this* runtime: which tools exist, what the profile refuses, and the one rule
|
|
8
|
-
* the guide states for editors but not for an agent that can run commands itself.
|
|
9
|
-
*/
|
|
3
|
+
// Only what `AGENTS.md` does not say: the guide is already loaded whole on every turn.
|
|
10
4
|
export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
11
5
|
const lines = [
|
|
12
6
|
"You are the akan coding agent, running inside an Akan.js workspace.",
|
|
@@ -40,8 +34,6 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
40
34
|
lines.push(
|
|
41
35
|
"- Secrets are refused at the tool boundary. If you need a value from `.env`, ask the user for it rather than looking for another way to read the file.",
|
|
42
36
|
"",
|
|
43
|
-
// The answer lands in a terminal pane, not a document: a preamble scrolls the thing being read off the
|
|
44
|
-
// screen, and a summary of tool calls repeats rows the host already drew above the answer.
|
|
45
37
|
"## How to answer",
|
|
46
38
|
"",
|
|
47
39
|
"- Answer in as few words as carry the answer. A short question gets one sentence with no heading.",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { type CodeAgentEvent, type CodeAgentProfile, codeAgentPresets } from "akanjs/common";
|
|
6
|
+
import { WorkspaceExecutor } from "../../executors";
|
|
7
|
+
import { isolateEnv, tempDirs } from "../../testHelpers";
|
|
8
|
+
import { AkanCodePlugins } from "../tools/AkanCodePlugins";
|
|
9
|
+
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
10
|
+
import { akanCodePaths } from "./akanCodePaths";
|
|
11
|
+
import { CodeAgent, type CodeAgentOptions } from "./CodeAgent";
|
|
12
|
+
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
13
|
+
import { SubagentPool } from "./SubagentPool";
|
|
14
|
+
|
|
15
|
+
isolateEnv();
|
|
16
|
+
const makeTempRoot = tempDirs("akan-code-agent-");
|
|
17
|
+
|
|
18
|
+
const chunk = (delta: object, finish: string | null = null) =>
|
|
19
|
+
`data: ${JSON.stringify({
|
|
20
|
+
id: "c1",
|
|
21
|
+
object: "chat.completion.chunk",
|
|
22
|
+
created: 0,
|
|
23
|
+
model: akanCodeDefaultModel.id,
|
|
24
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
25
|
+
})}\n\n`;
|
|
26
|
+
|
|
27
|
+
let llm: ReturnType<typeof Bun.serve>;
|
|
28
|
+
let llmCalls = 0;
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
llm = Bun.serve({
|
|
31
|
+
port: 0,
|
|
32
|
+
fetch: () => {
|
|
33
|
+
llmCalls += 1;
|
|
34
|
+
return new Response(`${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}data: [DONE]\n\n`, {
|
|
35
|
+
headers: { "content-type": "text/event-stream" },
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
afterAll(() => llm.stop(true));
|
|
41
|
+
|
|
42
|
+
const useProxy = (root: string) => {
|
|
43
|
+
process.env.AKAN_CODE_HOME = path.join(root, "home");
|
|
44
|
+
process.env.AKAN_CODE_PROXY_URL = `http://127.0.0.1:${llm.port}/llm/{provider}/v1`;
|
|
45
|
+
process.env.AKAN_CODE_PROXY_TOKEN = "test-token";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const quietProfile = (root: string): CodeAgentProfile => {
|
|
49
|
+
const web = codeAgentPresets.web(root);
|
|
50
|
+
return {
|
|
51
|
+
...web,
|
|
52
|
+
tools: { builtin: [], akan: false, mcp: "off", subagent: false, web: { fetch: false, search: false } },
|
|
53
|
+
context: { projectFiles: false, skills: false },
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const createAgent = async (root: string, options: Partial<CodeAgentOptions> = {}) => {
|
|
58
|
+
useProxy(root);
|
|
59
|
+
return await CodeAgent.create({
|
|
60
|
+
workspace: new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }),
|
|
61
|
+
profile: quietProfile(root),
|
|
62
|
+
...options,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("CodeAgent", () => {
|
|
67
|
+
test("a prompt typed instead of an answer drops the parked asks", async () => {
|
|
68
|
+
const root = await makeTempRoot();
|
|
69
|
+
const agent = await createAgent(root);
|
|
70
|
+
try {
|
|
71
|
+
await agent.ask({ prompt: "Which app?", kind: "select", options: [{ key: "a", label: "akan" }] });
|
|
72
|
+
const suspendedFile = CodeAgentSuspended.fileOf(akanCodePaths.sessionsDir(root), agent.sessionId) ?? "";
|
|
73
|
+
expect(existsSync(suspendedFile)).toBe(true);
|
|
74
|
+
|
|
75
|
+
await agent.prompt("Do something else instead.");
|
|
76
|
+
|
|
77
|
+
const events: CodeAgentEvent[] = [];
|
|
78
|
+
agent.on((event) => events.push(event));
|
|
79
|
+
agent.announce();
|
|
80
|
+
expect(events.filter((event) => event.type === "question")).toEqual([]);
|
|
81
|
+
expect(existsSync(suspendedFile)).toBe(false);
|
|
82
|
+
} finally {
|
|
83
|
+
agent.dispose();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a sub-agent hands its plugins the token tally of the tree that opened it", async () => {
|
|
88
|
+
const root = await makeTempRoot();
|
|
89
|
+
const build = AkanCodePlugins.build;
|
|
90
|
+
const seen: unknown[] = [];
|
|
91
|
+
const spy = spyOn(AkanCodePlugins, "build").mockImplementation(async (options) => {
|
|
92
|
+
seen.push(options.subagentSpend);
|
|
93
|
+
return await build.call(AkanCodePlugins, options);
|
|
94
|
+
});
|
|
95
|
+
const subagentSpend = { tokens: 7, running: 0 };
|
|
96
|
+
try {
|
|
97
|
+
const agent = await createAgent(root, { subagentSpend, depth: 1 });
|
|
98
|
+
agent.dispose();
|
|
99
|
+
expect(seen).toHaveLength(1);
|
|
100
|
+
expect(seen[0]).toBe(subagentSpend);
|
|
101
|
+
} finally {
|
|
102
|
+
spy.mockRestore();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("a sub-agent its parent stops before the sub-agent's run is live", () => {
|
|
108
|
+
type ToolExecute = (
|
|
109
|
+
id: string,
|
|
110
|
+
params: never,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
) => Promise<{ content: { text: string }[] } | undefined>;
|
|
113
|
+
|
|
114
|
+
const runTask = async (root: string, parent: AbortController, notices: string[]) => {
|
|
115
|
+
useProxy(root);
|
|
116
|
+
const profile = quietProfile(root);
|
|
117
|
+
const pool = SubagentPool.extensionFor({
|
|
118
|
+
workspace: new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }),
|
|
119
|
+
cwd: root,
|
|
120
|
+
parent: { ...profile, tools: { ...profile.tools, subagent: { maxDepth: 2, maxConcurrent: 3, budget: 1_000 } } },
|
|
121
|
+
depth: 0,
|
|
122
|
+
onNotice: (message) => notices.push(message),
|
|
123
|
+
});
|
|
124
|
+
let execute: ToolExecute | undefined;
|
|
125
|
+
if (pool && "factory" in pool)
|
|
126
|
+
pool.factory({
|
|
127
|
+
registerTool: (tool: { execute: ToolExecute }) => {
|
|
128
|
+
execute = tool.execute;
|
|
129
|
+
},
|
|
130
|
+
} as never);
|
|
131
|
+
const result = await execute?.("c1", { description: "d", prompt: "p" } as never, parent.signal);
|
|
132
|
+
return result?.content[0]?.text;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
test("stopped while it is being created, it never reaches the model and says it stopped", async () => {
|
|
136
|
+
const root = await makeTempRoot();
|
|
137
|
+
const parent = new AbortController();
|
|
138
|
+
const create = CodeAgent.create;
|
|
139
|
+
const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
|
|
140
|
+
const agent = await create.call(CodeAgent, options);
|
|
141
|
+
parent.abort();
|
|
142
|
+
return agent;
|
|
143
|
+
});
|
|
144
|
+
const notices: string[] = [];
|
|
145
|
+
try {
|
|
146
|
+
const calls = llmCalls;
|
|
147
|
+
expect(await runTask(root, parent, notices)).toBe("(no answer)");
|
|
148
|
+
expect(llmCalls).toBe(calls);
|
|
149
|
+
expect(notices.at(-1)).toBe("explore sub-agent stopped, 0 tokens (0 of 1000 spent)");
|
|
150
|
+
} finally {
|
|
151
|
+
spy.mockRestore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("stopped after it exists but before its run is live, it is stopped as soon as the run starts", async () => {
|
|
156
|
+
const root = await makeTempRoot();
|
|
157
|
+
const parent = new AbortController();
|
|
158
|
+
const stopBeforeTheRun: InlineExtension = {
|
|
159
|
+
name: "stop-before-the-run",
|
|
160
|
+
factory: (pi: ExtensionAPI) => {
|
|
161
|
+
pi.on("before_agent_start", () => {
|
|
162
|
+
parent.abort();
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const create = CodeAgent.create;
|
|
167
|
+
const spy = spyOn(CodeAgent, "create").mockImplementation(
|
|
168
|
+
async (options) => await create.call(CodeAgent, { ...options, extensions: [stopBeforeTheRun] }),
|
|
169
|
+
);
|
|
170
|
+
const notices: string[] = [];
|
|
171
|
+
try {
|
|
172
|
+
expect(await runTask(root, parent, notices)).toBe("(no answer)");
|
|
173
|
+
expect(notices.at(-1)).toMatch(/^explore sub-agent stopped, /);
|
|
174
|
+
} finally {
|
|
175
|
+
spy.mockRestore();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -34,6 +34,8 @@ class FakeTransport implements CodeAgentTransport {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const event = (seq: number, body: Partial<CodeAgentEvent> = {}) => ({ type: "idle", seq, ...body }) as CodeAgentEvent;
|
|
37
|
+
const start = { type: "agent_start" } as never;
|
|
38
|
+
const finished = (stopReason: string) => ({ type: "agent_end", messages: [{ stopReason }], willRetry: false }) as never;
|
|
37
39
|
|
|
38
40
|
describe("CodeAgentClient", () => {
|
|
39
41
|
test("correlates a reply with the command that asked for it", async () => {
|
|
@@ -207,8 +209,6 @@ describe("CodeAgentAsks", () => {
|
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
describe("CodeAgentEventMapper", () => {
|
|
210
|
-
const start = { type: "agent_start" } as never;
|
|
211
|
-
|
|
212
212
|
test("a run becomes one turn, not one turn per round trip", () => {
|
|
213
213
|
const mapper = new CodeAgentEventMapper();
|
|
214
214
|
expect(mapper.map(start)).toEqual([{ type: "turn_start", turnId: "t1" }]);
|
|
@@ -231,10 +231,6 @@ describe("CodeAgentEventMapper", () => {
|
|
|
231
231
|
expect(think).toEqual([{ type: "thinking_delta", turnId: "t1", text: "hmm" }]);
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
-
/**
|
|
235
|
-
* `String({})` is `"[object Object]"`, and an `edit` call's argument is an array of them — so a row that
|
|
236
|
-
* should read "three replacements" read as two identical placeholders instead.
|
|
237
|
-
*/
|
|
238
234
|
test("a structured argument is described by its shape, not stringified", () => {
|
|
239
235
|
const mapper = new CodeAgentEventMapper();
|
|
240
236
|
mapper.map(start);
|
|
@@ -248,7 +244,6 @@ describe("CodeAgentEventMapper", () => {
|
|
|
248
244
|
expect(title).not.toContain("[object Object]");
|
|
249
245
|
expect(title).toContain("edits=2 items");
|
|
250
246
|
expect(title).toContain("options={1 key}");
|
|
251
|
-
// A path still reads as itself: it is the one argument worth showing verbatim.
|
|
252
247
|
expect(title).toContain("path=/repo/a.ts");
|
|
253
248
|
});
|
|
254
249
|
|
|
@@ -289,8 +284,7 @@ describe("CodeAgentEventMapper", () => {
|
|
|
289
284
|
test("reports an aborted run as aborted rather than done", () => {
|
|
290
285
|
const mapper = new CodeAgentEventMapper();
|
|
291
286
|
mapper.map(start);
|
|
292
|
-
|
|
293
|
-
expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
287
|
+
expect(mapper.map(finished("aborted"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
294
288
|
});
|
|
295
289
|
|
|
296
290
|
test("a compaction that failed or was cancelled carries that, not just its reason", () => {
|
|
@@ -435,10 +429,6 @@ describe("codeAgentShouldPersist", () => {
|
|
|
435
429
|
});
|
|
436
430
|
|
|
437
431
|
describe("turn outcome", () => {
|
|
438
|
-
const start = { type: "agent_start" } as never;
|
|
439
|
-
const finished = (stopReason: string) =>
|
|
440
|
-
({ type: "agent_end", messages: [{ stopReason }], willRetry: false }) as never;
|
|
441
|
-
|
|
442
432
|
test("an abort is reported even though the engine calls it a normal stop", () => {
|
|
443
433
|
const mapper = new CodeAgentEventMapper();
|
|
444
434
|
mapper.map(start);
|
|
@@ -477,25 +467,17 @@ describe("turn outcome", () => {
|
|
|
477
467
|
});
|
|
478
468
|
|
|
479
469
|
describe("truncation", () => {
|
|
480
|
-
const start = { type: "agent_start" } as never;
|
|
481
|
-
|
|
482
470
|
test("the engine's `length` is a partial success, not a completed turn", () => {
|
|
483
471
|
const mapper = new CodeAgentEventMapper();
|
|
484
472
|
mapper.map(start);
|
|
485
|
-
|
|
486
|
-
type: "agent_end",
|
|
487
|
-
messages: [{ stopReason: "length" }],
|
|
488
|
-
willRetry: false,
|
|
489
|
-
} as never);
|
|
490
|
-
expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "truncated" }]);
|
|
473
|
+
expect(mapper.map(finished("length"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "truncated" }]);
|
|
491
474
|
});
|
|
492
475
|
|
|
493
476
|
test("an abort still wins over the engine's report", () => {
|
|
494
477
|
const mapper = new CodeAgentEventMapper();
|
|
495
478
|
mapper.map(start);
|
|
496
479
|
mapper.noteOutcome("aborted");
|
|
497
|
-
|
|
498
|
-
expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
480
|
+
expect(mapper.map(finished("length"))).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
499
481
|
});
|
|
500
482
|
|
|
501
483
|
test("the terminal says so, because a cut answer looks like a finished one", () => {
|
|
@@ -526,17 +508,12 @@ describe("akanCodeModelWarnings", () => {
|
|
|
526
508
|
});
|
|
527
509
|
|
|
528
510
|
describe("wire coverage", () => {
|
|
529
|
-
/**
|
|
530
|
-
* The audit that found `context` declared, rendered, and never emitted. A contract entry with no producer
|
|
531
|
-
* costs every host a branch for a frame that cannot arrive.
|
|
532
|
-
*/
|
|
533
511
|
test("every event type either has a producer in the core or is documented as host-supplied", async () => {
|
|
534
512
|
const dir = import.meta.dir;
|
|
535
513
|
const sources = await Promise.all(
|
|
536
514
|
["CodeAgent.ts", "CodeAgentEventMapper.ts"].map(async (file) => await Bun.file(`${dir}/${file}`).text()),
|
|
537
515
|
);
|
|
538
516
|
const core = sources.join("\n");
|
|
539
|
-
// `host` is the one frame the core never makes: it is the slot a host fills with its own vocabulary.
|
|
540
517
|
const hostSupplied = new Set(["host"]);
|
|
541
518
|
const missing = Object.keys(codeAgentEventPersistence).filter(
|
|
542
519
|
(type) => !hostSupplied.has(type) && !core.includes(`type: "${type}"`),
|