jeopi-cli 16.2.23 → 16.2.25
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/CHANGELOG.md +33 -0
- package/dist/cli.js +3011 -2970
- package/dist/types/advisor/runtime.d.ts +9 -1
- package/dist/types/cli/update-cli.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/extensibility/plugins/loader.d.ts +4 -4
- package/dist/types/extensibility/plugins/trust.d.ts +30 -0
- package/dist/types/goals/completion-evidence.d.ts +21 -0
- package/dist/types/goals/runtime.d.ts +1 -1
- package/dist/types/goals/state.d.ts +3 -0
- package/dist/types/goals/tools/goal-tool.d.ts +2 -0
- package/dist/types/main.d.ts +20 -0
- package/dist/types/mnemopi/state.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/task/critic-gate.d.ts +34 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/subagent.d.ts +70 -0
- package/dist/types/tools/subagent.test.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +173 -0
- package/src/advisor/runtime.ts +66 -2
- package/src/cli/update-cli.ts +144 -0
- package/src/config/settings-schema.ts +14 -2
- package/src/extensibility/plugins/loader.ts +35 -9
- package/src/extensibility/plugins/trust.ts +133 -0
- package/src/goals/completion-evidence.ts +73 -0
- package/src/goals/runtime.ts +6 -1
- package/src/goals/state.ts +3 -0
- package/src/goals/tools/goal-tool.ts +16 -1
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/main.ts +56 -10
- package/src/memories/index.ts +21 -1
- package/src/mnemopi/state.ts +10 -2
- package/src/modes/interactive-mode.ts +21 -0
- package/src/prompts/memories/read-path.md +3 -1
- package/src/prompts/system/safety-kernel.md +17 -0
- package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/subagent.md +29 -0
- package/src/session/agent-session.ts +68 -19
- package/src/session/session-context.ts +8 -2
- package/src/slash-commands/builtin-registry.ts +28 -0
- package/src/slash-commands/types.ts +2 -0
- package/src/system-prompt.ts +14 -2
- package/src/task/critic-gate.ts +46 -0
- package/src/task/index.ts +63 -2
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/subagent.test.ts +305 -0
- package/src/tools/subagent.ts +591 -0
package/src/task/critic-gate.ts
CHANGED
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* input, even re-submitting to `critic` is refused — the agent must stop and
|
|
23
23
|
* report the surviving gaps instead of burning tokens on convergence-free
|
|
24
24
|
* re-planning.
|
|
25
|
+
*
|
|
26
|
+
* `okay` clearing the gate is not the whole story: nothing previously stopped
|
|
27
|
+
* the plan file from being edited *after* the critic approved it and *before*
|
|
28
|
+
* a non-read-only agent executes it (a TOCTOU window — jeo-code closes the
|
|
29
|
+
* same gap with a `sha256(planContent) === consensus_hash` pin in
|
|
30
|
+
* `team.ts`/`approve.ts`). {@link ApprovedPlanHash} / {@link hashPlanContent} /
|
|
31
|
+
* {@link planIntegrityMismatchMessage} port that pin: the caller records the
|
|
32
|
+
* approved plan's hash when a verdict resolves `okay`, and re-checks it
|
|
33
|
+
* immediately before spawning a non-read-only agent.
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
/** Agent name whose structured verdicts drive the gate. */
|
|
@@ -146,3 +155,40 @@ export function criticGateWriteMessage(state: CriticGateState): string {
|
|
|
146
155
|
`(or the user sends a new message). Write plans and notes to local:// files instead.${formatRequiredFixes(state)}`
|
|
147
156
|
);
|
|
148
157
|
}
|
|
158
|
+
|
|
159
|
+
/** Canonical `local://` path for the plan the `/jeo` workflow gates on (see `prompts/agents/jeo.md`). */
|
|
160
|
+
export const GATED_PLAN_LOCAL_URL = "local://jeo-plan.md";
|
|
161
|
+
|
|
162
|
+
/** Hash of the plan content a critic verdict of `okay` approved, so a later
|
|
163
|
+
* spawn gate can detect the plan changing out from under an already-`okay`
|
|
164
|
+
* verdict. Held by the parent session alongside (but independent of)
|
|
165
|
+
* {@link CriticGateState} — it survives the gate clearing on `okay`, which is
|
|
166
|
+
* exactly when it starts mattering. */
|
|
167
|
+
export interface ApprovedPlanHash {
|
|
168
|
+
/** sha256 hex digest of the plan content at the moment of the `okay` verdict. */
|
|
169
|
+
hash: string;
|
|
170
|
+
/** Agent id of the critic run that approved this content. */
|
|
171
|
+
agentId: string;
|
|
172
|
+
/** Epoch ms when the verdict was recorded. */
|
|
173
|
+
at: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Stable hash of plan content for {@link ApprovedPlanHash} pinning. Not a
|
|
177
|
+
* cryptographic integrity boundary (the model can read its own hash target) —
|
|
178
|
+
* it exists to catch accidental or incidental drift between critic approval
|
|
179
|
+
* and execution, the same threat model as jeo-code's `consensus_hash`. */
|
|
180
|
+
export function hashPlanContent(content: string): string {
|
|
181
|
+
return new Bun.CryptoHasher("sha256").update(content).digest("hex");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Error text for a non-read-only spawn attempted when the plan content no
|
|
186
|
+
* longer matches what the critic approved (edited after `okay`, or deleted).
|
|
187
|
+
*/
|
|
188
|
+
export function planIntegrityMismatchMessage(approved: ApprovedPlanHash): string {
|
|
189
|
+
return (
|
|
190
|
+
`Critic gate: \`${GATED_PLAN_LOCAL_URL}\` no longer matches the content \`${approved.agentId}\` reviewed and approved. ` +
|
|
191
|
+
`The plan changed (or was removed) after the \`okay\` verdict — spawning a non-read-only agent is blocked by the runtime. ` +
|
|
192
|
+
"Re-submit the current plan to a fresh `critic` and execute only after a new `okay` verdict."
|
|
193
|
+
);
|
|
194
|
+
}
|
package/src/task/index.ts
CHANGED
|
@@ -45,10 +45,18 @@ import {
|
|
|
45
45
|
// Import review tools for side effects (registers subagent tool handlers)
|
|
46
46
|
import "../tools/review";
|
|
47
47
|
import type { AsyncJobManager } from "../async";
|
|
48
|
-
import type
|
|
48
|
+
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
49
49
|
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
|
|
50
50
|
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
CRITIC_AGENT_NAME,
|
|
53
|
+
evaluateCriticGateSpawn,
|
|
54
|
+
GATED_PLAN_LOCAL_URL,
|
|
55
|
+
hashPlanContent,
|
|
56
|
+
parseCriticVerdict,
|
|
57
|
+
planIntegrityMismatchMessage,
|
|
58
|
+
updateCriticGateState,
|
|
59
|
+
} from "./critic-gate";
|
|
52
60
|
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
|
|
53
61
|
import { runSubprocess } from "./executor";
|
|
54
62
|
import {
|
|
@@ -547,6 +555,30 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
547
555
|
return this.session.settings.get("task.batch");
|
|
548
556
|
}
|
|
549
557
|
|
|
558
|
+
/** Local protocol options for resolving `local://` paths outside a specific
|
|
559
|
+
* subagent spawn (e.g. the critic-gate plan-integrity check below). */
|
|
560
|
+
#sessionLocalProtocolOptions(): LocalProtocolOptions {
|
|
561
|
+
return (
|
|
562
|
+
this.session.localProtocolOptions ?? {
|
|
563
|
+
getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
|
|
564
|
+
getSessionId: this.session.getSessionId ?? (() => null),
|
|
565
|
+
}
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Read + hash `local://jeo-plan.md` for the critic-gate plan-integrity pin.
|
|
570
|
+
* Returns `null` when the file does not exist or can't be read — callers
|
|
571
|
+
* treat that as "no current plan to compare against". */
|
|
572
|
+
async #hashGatedPlanContent(): Promise<string | null> {
|
|
573
|
+
try {
|
|
574
|
+
const absolutePath = resolveLocalUrlToPath(GATED_PLAN_LOCAL_URL, this.#sessionLocalProtocolOptions());
|
|
575
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
576
|
+
return hashPlanContent(content);
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
550
582
|
#getSpawnSemaphore(): Semaphore {
|
|
551
583
|
const max = this.session.settings.get("task.maxConcurrency");
|
|
552
584
|
if (this.#spawnSemaphore) {
|
|
@@ -1134,6 +1166,21 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1134
1166
|
};
|
|
1135
1167
|
}
|
|
1136
1168
|
|
|
1169
|
+
// Plan-integrity pin: even with the gate clear (latest verdict `okay`), a
|
|
1170
|
+
// non-read-only spawn is refused if `local://jeo-plan.md` no longer matches
|
|
1171
|
+
// the content that verdict approved — closes the TOCTOU window between
|
|
1172
|
+
// critic approval and execution (see task/critic-gate.ts).
|
|
1173
|
+
const approvedPlanHash = this.session.getApprovedPlanHash?.();
|
|
1174
|
+
if (approvedPlanHash && !isReadOnlyAgent(agent)) {
|
|
1175
|
+
const currentHash = await this.#hashGatedPlanContent();
|
|
1176
|
+
if (currentHash !== approvedPlanHash.hash) {
|
|
1177
|
+
return {
|
|
1178
|
+
content: [{ type: "text", text: planIntegrityMismatchMessage(approvedPlanHash) }],
|
|
1179
|
+
details: { projectAgentsDir, results: [], totalDurationMs: 0 },
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1137
1184
|
const planModeState = this.session.getPlanModeState?.();
|
|
1138
1185
|
const planModeBaseTools = ["read", "grep", "glob", "lsp", "web_search"];
|
|
1139
1186
|
const planModeTools = [
|
|
@@ -1418,6 +1465,20 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1418
1465
|
this.session.setCriticGateState(
|
|
1419
1466
|
updateCriticGateState(this.session.getCriticGateState?.(), parsed, result.id),
|
|
1420
1467
|
);
|
|
1468
|
+
// Plan-integrity pin: an `okay` verdict records the current plan
|
|
1469
|
+
// content's hash so a later spawn can detect the plan changing out
|
|
1470
|
+
// from under this exact approval (see the check above and
|
|
1471
|
+
// task/critic-gate.ts). A non-okay verdict clears any prior pin —
|
|
1472
|
+
// the stronger critic-gate write/spawn lock takes over until the
|
|
1473
|
+
// next `okay`, so a stale pin has no protective value to keep.
|
|
1474
|
+
if (this.session.setApprovedPlanHash) {
|
|
1475
|
+
if (parsed.verdict === "okay") {
|
|
1476
|
+
const hash = await this.#hashGatedPlanContent();
|
|
1477
|
+
this.session.setApprovedPlanHash(hash ? { hash, agentId: result.id, at: Date.now() } : undefined);
|
|
1478
|
+
} else {
|
|
1479
|
+
this.session.setApprovedPlanHash(undefined);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1421
1482
|
}
|
|
1422
1483
|
}
|
|
1423
1484
|
|
package/src/tools/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type { CustomMessage } from "../session/messages";
|
|
|
27
27
|
import type { UsageStatistics } from "../session/session-entries";
|
|
28
28
|
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
|
|
29
29
|
import { TaskTool } from "../task";
|
|
30
|
-
import type { CriticGateState } from "../task/critic-gate";
|
|
30
|
+
import type { ApprovedPlanHash, CriticGateState } from "../task/critic-gate";
|
|
31
31
|
import type { AgentOutputManager } from "../task/output-manager";
|
|
32
32
|
import { canSpawnAtDepth } from "../task/types";
|
|
33
33
|
import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
|
|
@@ -64,6 +64,7 @@ import { ResolveTool } from "./resolve";
|
|
|
64
64
|
import { reportFindingTool } from "./review";
|
|
65
65
|
import { SearchToolBm25Tool } from "./search-tool-bm25";
|
|
66
66
|
import { loadSshTool } from "./ssh";
|
|
67
|
+
import { isSubagentToolEnabled, SubagentTool } from "./subagent";
|
|
67
68
|
import { type TodoPhase, TodoTool } from "./todo";
|
|
68
69
|
import { WriteTool } from "./write";
|
|
69
70
|
import { YieldTool } from "./yield";
|
|
@@ -102,6 +103,7 @@ export * from "./resolve";
|
|
|
102
103
|
export * from "./review";
|
|
103
104
|
export * from "./search-tool-bm25";
|
|
104
105
|
export * from "./ssh";
|
|
106
|
+
export * from "./subagent";
|
|
105
107
|
export * from "./todo";
|
|
106
108
|
export * from "./tts";
|
|
107
109
|
export * from "./write";
|
|
@@ -278,6 +280,10 @@ export interface ToolSession {
|
|
|
278
280
|
getCriticGateState?: () => CriticGateState | undefined;
|
|
279
281
|
/** Replace the critic gate state (undefined clears the gate). */
|
|
280
282
|
setCriticGateState?: (state: CriticGateState | undefined) => void;
|
|
283
|
+
/** Plan content hash a critic verdict of `okay` approved (see `task/critic-gate.ts`); survives the gate clearing on `okay` so a later spawn can detect post-approval plan drift. */
|
|
284
|
+
getApprovedPlanHash?: () => ApprovedPlanHash | undefined;
|
|
285
|
+
/** Replace the approved-plan hash (undefined clears it). */
|
|
286
|
+
setApprovedPlanHash?: (hash: ApprovedPlanHash | undefined) => void;
|
|
281
287
|
/** Goal mode state (if active or paused) */
|
|
282
288
|
getGoalModeState?: () => GoalModeState | undefined;
|
|
283
289
|
/** Goal runtime for the active agent session. */
|
|
@@ -466,6 +472,7 @@ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
|
|
|
466
472
|
task: s => TaskTool.create(s),
|
|
467
473
|
job: s => new JobTool(s),
|
|
468
474
|
irc: IrcTool.createIf,
|
|
475
|
+
subagent: SubagentTool.createIf,
|
|
469
476
|
todo: s => new TodoTool(s),
|
|
470
477
|
web_search: s => new WebSearchTool(s),
|
|
471
478
|
search_tool_bm25: SearchToolBm25Tool.createIf,
|
|
@@ -620,6 +627,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
620
627
|
if (name === "browser") return session.settings.get("browser.enabled");
|
|
621
628
|
if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
|
|
622
629
|
if (name === "irc") return isIrcEnabled(session.settings, session.taskDepth ?? 0);
|
|
630
|
+
if (name === "subagent") return isSubagentToolEnabled(session.settings, session.taskDepth ?? 0);
|
|
623
631
|
if (name === "retain" || name === "recall" || name === "reflect") {
|
|
624
632
|
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
|
|
625
633
|
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { AsyncJobManager } from "../async";
|
|
3
|
+
import { Settings } from "../config/settings";
|
|
4
|
+
import { IrcBus } from "../irc/bus";
|
|
5
|
+
import { AgentRegistry } from "../registry/agent-registry";
|
|
6
|
+
import type { AgentSession } from "../session/agent-session";
|
|
7
|
+
import type { ToolSession } from "./index";
|
|
8
|
+
import { SubagentTool } from "./subagent";
|
|
9
|
+
import { ToolError } from "./tool-errors";
|
|
10
|
+
|
|
11
|
+
function makeSession(opts: { agentId: string; registry: AgentRegistry; manager?: AsyncJobManager }): ToolSession {
|
|
12
|
+
return {
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
hasUI: false,
|
|
15
|
+
settings: Settings.isolated(),
|
|
16
|
+
getSessionFile: () => null,
|
|
17
|
+
getSessionSpawns: () => "*",
|
|
18
|
+
agentRegistry: opts.registry,
|
|
19
|
+
asyncJobManager: opts.manager,
|
|
20
|
+
getAgentId: () => opts.agentId,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
25
|
+
return result.content.find(part => part.type === "text")?.text ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Registers a `task` job whose `run` never resolves until the test resolves it. */
|
|
29
|
+
function registerPendingTaskJob(manager: AsyncJobManager, ownerId: string, id: string) {
|
|
30
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
31
|
+
manager.register(
|
|
32
|
+
"task",
|
|
33
|
+
id,
|
|
34
|
+
async ({ markRunning }) => {
|
|
35
|
+
markRunning();
|
|
36
|
+
return promise;
|
|
37
|
+
},
|
|
38
|
+
{ id, ownerId },
|
|
39
|
+
);
|
|
40
|
+
return { resolve };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("SubagentTool", () => {
|
|
44
|
+
let registry: AgentRegistry;
|
|
45
|
+
let manager: AsyncJobManager;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
// `IrcBus.global()` (used internally by steer/resume/pause) always routes
|
|
49
|
+
// through `AgentRegistry.global()`, so the fake session must live there
|
|
50
|
+
// too for delivery to resolve the same refs `list`/`inspect` see.
|
|
51
|
+
AgentRegistry.resetGlobalForTests();
|
|
52
|
+
registry = AgentRegistry.global();
|
|
53
|
+
manager = new AsyncJobManager({ onJobComplete: () => {} });
|
|
54
|
+
IrcBus.resetGlobalForTests();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
manager.cancelAll();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("scopes `list` to the calling agent's own spawns", async () => {
|
|
62
|
+
registerPendingTaskJob(manager, "Main", "Sub1");
|
|
63
|
+
registerPendingTaskJob(manager, "Other", "Sub2");
|
|
64
|
+
|
|
65
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
66
|
+
const result = await tool.execute("call-1", { action: "list" });
|
|
67
|
+
|
|
68
|
+
expect(result.details?.subagents.map(s => s.id)).toEqual(["Sub1"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports a spawn as "queued" until its runner calls markRunning', async () => {
|
|
72
|
+
const { promise, resolve } = Promise.withResolvers<string>();
|
|
73
|
+
manager.register("task", "Sub1", async () => promise, { id: "Sub1", ownerId: "Main", queued: true });
|
|
74
|
+
|
|
75
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
76
|
+
const result = await tool.execute("call-1", { action: "list" });
|
|
77
|
+
|
|
78
|
+
expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "queued" });
|
|
79
|
+
resolve("cleanup");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects cancel without a target id", async () => {
|
|
83
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
84
|
+
await expect(tool.execute("call-1", { action: "cancel" })).rejects.toThrow(ToolError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("classifies cancel outcomes: cancelled, not_found, already_completed", async () => {
|
|
88
|
+
const { resolve } = registerPendingTaskJob(manager, "Main", "Running1");
|
|
89
|
+
manager.register("task", "Done1", async () => "done", { id: "Done1", ownerId: "Main" });
|
|
90
|
+
// Let Done1 settle before cancelling it.
|
|
91
|
+
await manager.getJob("Done1")?.promise;
|
|
92
|
+
|
|
93
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
94
|
+
const result = await tool.execute("call-1", { action: "cancel", ids: ["Running1", "Done1", "Ghost"] });
|
|
95
|
+
|
|
96
|
+
expect(result.details?.cancelled).toEqual(
|
|
97
|
+
expect.arrayContaining([
|
|
98
|
+
{ id: "Running1", status: "cancelled" },
|
|
99
|
+
{ id: "Done1", status: "already_completed" },
|
|
100
|
+
{ id: "Ghost", status: "not_found" },
|
|
101
|
+
]),
|
|
102
|
+
);
|
|
103
|
+
resolve("cleanup");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("cannot cancel a job owned by a different agent", async () => {
|
|
107
|
+
const { resolve } = registerPendingTaskJob(manager, "Other", "Sub1");
|
|
108
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
109
|
+
const result = await tool.execute("call-1", { action: "cancel", ids: ["Sub1"] });
|
|
110
|
+
|
|
111
|
+
expect(result.details?.cancelled).toEqual([{ id: "Sub1", status: "not_found" }]);
|
|
112
|
+
expect(manager.getJob("Sub1")?.status).toBe("running");
|
|
113
|
+
resolve("cleanup");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects steer/resume/pause targeting the caller itself", async () => {
|
|
117
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
118
|
+
await expect(tool.execute("c1", { action: "steer", id: "Main", message: "hi" })).rejects.toThrow(ToolError);
|
|
119
|
+
await expect(tool.execute("c2", { action: "resume", id: "Main", message: "hi" })).rejects.toThrow(ToolError);
|
|
120
|
+
await expect(tool.execute("c3", { action: "pause", id: "Main" })).rejects.toThrow(ToolError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("resume is a no-op on a still-running subagent and does not require a message", async () => {
|
|
124
|
+
const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
|
|
125
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "running" });
|
|
126
|
+
|
|
127
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
128
|
+
const result = await tool.execute("call-1", { action: "resume", id: "Sub1" });
|
|
129
|
+
|
|
130
|
+
expect(result.isError).toBeFalsy();
|
|
131
|
+
expect(textOf(result)).toContain("already running");
|
|
132
|
+
resolve("cleanup");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resume requires a message once the subagent has finished", async () => {
|
|
136
|
+
manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
|
|
137
|
+
await manager.getJob("Sub1")?.promise;
|
|
138
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
|
|
139
|
+
|
|
140
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
141
|
+
await expect(tool.execute("call-1", { action: "resume", id: "Sub1" })).rejects.toThrow(ToolError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("marks a finished-but-resident subagent as resumable in its snapshot", async () => {
|
|
145
|
+
manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
|
|
146
|
+
await manager.getJob("Sub1")?.promise;
|
|
147
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
|
|
148
|
+
|
|
149
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
150
|
+
const result = await tool.execute("call-1", { action: "list" });
|
|
151
|
+
|
|
152
|
+
expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "completed", resumable: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("pause is a no-op once the subagent already finished", async () => {
|
|
156
|
+
manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
|
|
157
|
+
await manager.getJob("Sub1")?.promise;
|
|
158
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
|
|
159
|
+
|
|
160
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
161
|
+
const result = await tool.execute("call-1", { action: "pause", id: "Sub1" });
|
|
162
|
+
|
|
163
|
+
expect(textOf(result)).toContain("not running");
|
|
164
|
+
});
|
|
165
|
+
it('pause marks a still-running subagent "paused"; a later resume clears it', async () => {
|
|
166
|
+
const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
|
|
167
|
+
const fakeSession = { deliverIrcMessage: async () => "injected" as const } as unknown as AgentSession;
|
|
168
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: fakeSession, status: "running" });
|
|
169
|
+
|
|
170
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
171
|
+
const pauseResult = await tool.execute("c1", { action: "pause", id: "Sub1" });
|
|
172
|
+
expect(pauseResult.isError).toBeFalsy();
|
|
173
|
+
|
|
174
|
+
// Simulate the subagent actually reaching a safe boundary and stopping in
|
|
175
|
+
// response to the pause request (a real run would flip both together).
|
|
176
|
+
resolve("stopped early");
|
|
177
|
+
await manager.getJob("Sub1")?.promise;
|
|
178
|
+
registry.setStatus("Sub1", "idle");
|
|
179
|
+
|
|
180
|
+
const paused = await tool.execute("c2", { action: "list" });
|
|
181
|
+
expect(paused.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "paused", resumable: true });
|
|
182
|
+
|
|
183
|
+
const resumeResult = await tool.execute("c3", { action: "resume", id: "Sub1", message: "continue" });
|
|
184
|
+
expect(resumeResult.isError).toBeFalsy();
|
|
185
|
+
|
|
186
|
+
const afterResume = await tool.execute("c4", { action: "list" });
|
|
187
|
+
expect(afterResume.details?.subagents[0].status).not.toBe("paused");
|
|
188
|
+
});
|
|
189
|
+
it("resume without a message succeeds on a paused subagent (GJC parity) but still requires one once terminal", async () => {
|
|
190
|
+
const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
|
|
191
|
+
const fakeSession = { deliverIrcMessage: async () => "injected" as const } as unknown as AgentSession;
|
|
192
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: fakeSession, status: "running" });
|
|
193
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
194
|
+
|
|
195
|
+
await tool.execute("c1", { action: "pause", id: "Sub1" });
|
|
196
|
+
resolve("stopped early");
|
|
197
|
+
await manager.getJob("Sub1")?.promise;
|
|
198
|
+
registry.setStatus("Sub1", "idle");
|
|
199
|
+
|
|
200
|
+
// No `message` — a paused subagent has known intent (we asked it to stop),
|
|
201
|
+
// so resume proceeds with a default continuation body instead of throwing.
|
|
202
|
+
const resumed = await tool.execute("c2", { action: "resume", id: "Sub1" });
|
|
203
|
+
expect(resumed.isError).toBeFalsy();
|
|
204
|
+
expect(resumed.details?.receipt?.outcome).toBe("injected");
|
|
205
|
+
|
|
206
|
+
// Once genuinely terminal again (no outstanding pause), resume still needs a message.
|
|
207
|
+
await expect(tool.execute("c3", { action: "resume", id: "Sub1" })).rejects.toThrow(ToolError);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("a subagent that goes idle while running is never mistaken for paused", async () => {
|
|
211
|
+
const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
|
|
212
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "running" });
|
|
213
|
+
resolve("finished on its own");
|
|
214
|
+
await manager.getJob("Sub1")?.promise;
|
|
215
|
+
registry.setStatus("Sub1", "idle");
|
|
216
|
+
|
|
217
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
218
|
+
const result = await tool.execute("call-1", { action: "list" });
|
|
219
|
+
|
|
220
|
+
expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "completed", resumable: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("cancel aborts a live resumed session even without an active async job", async () => {
|
|
224
|
+
const abortReasons: (string | undefined)[] = [];
|
|
225
|
+
const fakeSession = {
|
|
226
|
+
abort: async (opts?: { reason?: string }) => {
|
|
227
|
+
abortReasons.push(opts?.reason);
|
|
228
|
+
},
|
|
229
|
+
} as unknown as AgentSession;
|
|
230
|
+
manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
|
|
231
|
+
await manager.getJob("Sub1")?.promise;
|
|
232
|
+
registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: fakeSession, status: "running" });
|
|
233
|
+
|
|
234
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
235
|
+
const result = await tool.execute("call-1", { action: "cancel", id: "Sub1" });
|
|
236
|
+
|
|
237
|
+
expect(result.details?.cancelled).toEqual([{ id: "Sub1", status: "cancelled" }]);
|
|
238
|
+
expect(abortReasons).toEqual(["Cancelled via subagent tool"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("applies `limit` to `list` and reports how many were hidden", async () => {
|
|
242
|
+
registerPendingTaskJob(manager, "Main", "Sub1");
|
|
243
|
+
registerPendingTaskJob(manager, "Main", "Sub2");
|
|
244
|
+
registerPendingTaskJob(manager, "Main", "Sub3");
|
|
245
|
+
|
|
246
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
247
|
+
const result = await tool.execute("call-1", { action: "list", limit: 2 });
|
|
248
|
+
|
|
249
|
+
expect(result.details?.subagents).toHaveLength(2);
|
|
250
|
+
expect(result.details?.truncated).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("verbosity widths match GJC: receipt<=280, preview<=2000, full<=12000 chars", async () => {
|
|
254
|
+
const longOutput = "x".repeat(15_000);
|
|
255
|
+
manager.register("task", "Sub1", async () => longOutput, { id: "Sub1", ownerId: "Main" });
|
|
256
|
+
await manager.getJob("Sub1")?.promise;
|
|
257
|
+
|
|
258
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
259
|
+
|
|
260
|
+
// `verbosity` defaults to "receipt" per GJC (not "preview").
|
|
261
|
+
const defaulted = await tool.execute("c0", { action: "inspect", ids: ["Sub1"] });
|
|
262
|
+
expect(defaulted.details?.subagents[0].resultText).toHaveLength(280);
|
|
263
|
+
|
|
264
|
+
const receipt = await tool.execute("c1", { action: "inspect", ids: ["Sub1"], verbosity: "receipt" });
|
|
265
|
+
expect(receipt.details?.subagents[0].resultText).toHaveLength(280);
|
|
266
|
+
|
|
267
|
+
const preview = await tool.execute("c2", { action: "inspect", ids: ["Sub1"], verbosity: "preview" });
|
|
268
|
+
expect(preview.details?.subagents[0].resultText).toHaveLength(2_000);
|
|
269
|
+
|
|
270
|
+
const full = await tool.execute("c3", { action: "inspect", ids: ["Sub1"], verbosity: "full" });
|
|
271
|
+
expect(full.details?.subagents[0].resultText).toHaveLength(12_000);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('rejects verbosity="full" on `list`, and on any action without explicit ids', async () => {
|
|
275
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
276
|
+
await expect(tool.execute("c1", { action: "list", verbosity: "full" })).rejects.toThrow(ToolError);
|
|
277
|
+
await expect(tool.execute("c2", { action: "inspect", verbosity: "full" })).rejects.toThrow(ToolError);
|
|
278
|
+
// Explicit ids makes `full` valid for a non-list action.
|
|
279
|
+
const result = await tool.execute("c3", { action: "inspect", ids: ["Ghost"], verbosity: "full" });
|
|
280
|
+
expect(result.isError).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("`list` defaults to 10 results and clamps `limit` to a max of 50", async () => {
|
|
284
|
+
for (let i = 0; i < 12; i++) {
|
|
285
|
+
registerPendingTaskJob(manager, "Main", `Sub${i}`);
|
|
286
|
+
}
|
|
287
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
288
|
+
|
|
289
|
+
const defaulted = await tool.execute("c1", { action: "list" });
|
|
290
|
+
expect(defaulted.details?.subagents).toHaveLength(10);
|
|
291
|
+
expect(defaulted.details?.truncated).toBe(2);
|
|
292
|
+
|
|
293
|
+
const overshoot = await tool.execute("c2", { action: "list", limit: 1_000 });
|
|
294
|
+
expect(overshoot.details?.subagents).toHaveLength(12);
|
|
295
|
+
expect(overshoot.details?.truncated).toBeUndefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("accepts `timeout_ms` (snake_case, matching GJC) for `await`", async () => {
|
|
299
|
+
registerPendingTaskJob(manager, "Main", "Sub1");
|
|
300
|
+
const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
|
|
301
|
+
|
|
302
|
+
const result = await tool.execute("c1", { action: "await", ids: ["Sub1"], timeout_ms: 5 });
|
|
303
|
+
expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "running" });
|
|
304
|
+
});
|
|
305
|
+
});
|