@staix/agent-hub 0.12.44 → 0.12.45
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 +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +23 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAgyEntry } from "./entry.ts";
|
|
1
2
|
import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
2
3
|
import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
|
|
3
4
|
import { AGY_FACTS_HOOK } from "./hooks.ts";
|
|
@@ -15,6 +16,7 @@ function agyLaunch(ctx: LaunchContext): LaunchPlan {
|
|
|
15
16
|
export const AGY_DEFINITION: AdapterDefinition = {
|
|
16
17
|
api: 1,
|
|
17
18
|
kind: "agy",
|
|
19
|
+
projection: { roleRank: 7, helpRank: 4, plannerEligible: false },
|
|
18
20
|
displayName: "Antigravity",
|
|
19
21
|
family: "agy",
|
|
20
22
|
transport: "ws-pull",
|
|
@@ -50,7 +52,7 @@ export const AGY_DEFINITION: AdapterDefinition = {
|
|
|
50
52
|
recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
|
|
51
53
|
},
|
|
52
54
|
turnEnd: "native-hook",
|
|
53
|
-
cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
|
|
55
|
+
cli: { command: "agy", entry: { lifecycleAdmission: true, execute: runAgyEntry }, usage: [["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"]], authority: "person", startWords: () => ["agy"] },
|
|
54
56
|
display: DISPLAY,
|
|
55
57
|
routing: { allowedRoles: ["implementer", "verifier"] },
|
|
56
58
|
reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
|
|
@@ -157,6 +157,8 @@ const offline = () => detached ?? "hub is not running for this project (start it
|
|
|
157
157
|
* Surfaced on the next tool result; the pull itself still happens only through hub_inbox.
|
|
158
158
|
*/
|
|
159
159
|
let pendingQueued: { queued: number; important: number } | undefined;
|
|
160
|
+
/** Marks a tool result whose text already states the queued count (#221), so the fresh-count block does not repeat it. Symbol keys never reach the client. */
|
|
161
|
+
const QUEUED_COUNTED = Symbol("queuedCounted");
|
|
160
162
|
/** Fresh own-peer counts accompany all tool modes; only old plain sessions use advisory queued nudges. */
|
|
161
163
|
async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T> {
|
|
162
164
|
const r = result as { content?: { type: string; text?: string }[] };
|
|
@@ -169,23 +171,28 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
|
|
|
169
171
|
};
|
|
170
172
|
if (hub && r.content) {
|
|
171
173
|
let freshCounts = false;
|
|
174
|
+
let hint = "";
|
|
172
175
|
try {
|
|
173
176
|
const response = await hub.request({ t: "delivery_holds" }, 1000);
|
|
177
|
+
// A hold that refuses pulls (#221): hub_inbox would only be refused again, so the counts stay and the advice goes.
|
|
178
|
+
hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
|
|
174
179
|
if (response?.ok && Number.isSafeInteger(response.queued) && response.queued >= 0
|
|
175
180
|
&& Number.isSafeInteger(response.important) && response.important >= 0 && response.important <= response.queued) {
|
|
176
181
|
freshCounts = true;
|
|
177
182
|
pendingQueued = undefined; // A current zero must never resurrect an earlier queued nudge.
|
|
178
183
|
if (response.important > 0) {
|
|
179
|
-
const next = !recommendInbox ? "" : toolsMode
|
|
184
|
+
const next = !recommendInbox || hint ? "" : toolsMode
|
|
180
185
|
? ": use hub_inbox on a pull-capable peer session; otherwise finish the current turn to receive queued deliveries"
|
|
181
186
|
: ": call hub_inbox before starting long local work";
|
|
182
|
-
|
|
187
|
+
// One queued count per result (#221): a hub_inbox result that already says how many remain unread adds only the important count.
|
|
188
|
+
const counted = (result as { [QUEUED_COUNTED]?: boolean })[QUEUED_COUNTED] === true;
|
|
189
|
+
appendQueued(counted ? `(${response.important} important hub message(s) waiting${next})` : `(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
|
-
const hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
|
|
186
192
|
if (hint) r.content.push({ type: "text", text: hint });
|
|
187
193
|
} catch { /* advisory metadata can never change a saved tool result or its effect classification */ }
|
|
188
|
-
|
|
194
|
+
// A pre-#493 daemon sends held rows without counts; the legacy nudge must not advise a read the hold refuses (#221).
|
|
195
|
+
if (!freshCounts && !hint && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
|
|
189
196
|
appendQueued(`(${pendingQueued.important} important hub message(s) waiting: call hub_inbox before starting long local work)`);
|
|
190
197
|
}
|
|
191
198
|
}
|
|
@@ -391,7 +398,9 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
391
398
|
// Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
|
|
392
399
|
const out = [...inbox.splice(0), ...remote];
|
|
393
400
|
if (notice) out.push(notice);
|
|
394
|
-
|
|
401
|
+
const result = text(out.length ? out.join("\n\n") : "(no queued hub messages)");
|
|
402
|
+
if (notice.endsWith("remain unread)")) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
|
|
403
|
+
return result;
|
|
395
404
|
}
|
|
396
405
|
if (name === "hub_send") {
|
|
397
406
|
const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
|
|
@@ -488,6 +488,7 @@ export class CodexPeer extends BasePeer {
|
|
|
488
488
|
get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
|
|
489
489
|
|
|
490
490
|
getPermissionMode(): PermissionMode { return this.permissionMode; }
|
|
491
|
+
get permissionModeState(): PermissionMode { return this.getPermissionMode(); }
|
|
491
492
|
|
|
492
493
|
/** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
|
|
493
494
|
setStartupPermissionMode(mode: PermissionMode): void {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type { AcpPeer } from "../acp.ts";
|
|
6
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
7
|
+
import type { DeliveryReceipt } from "../../hub/peers.ts";
|
|
8
|
+
import { processTable } from "../../hub/child-process.ts";
|
|
9
|
+
import { until } from "./kit.ts";
|
|
10
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
11
|
+
import { fakePaths } from "./fake-paths.ts";
|
|
12
|
+
import { createFromDefinition, expire } from "./factories.ts";
|
|
13
|
+
import { ACP_LEGS, assertAcpLegEnrollment, type AcpLeg } from "./enrollment.ts";
|
|
14
|
+
|
|
15
|
+
export interface AcpConformanceOptions {
|
|
16
|
+
definition: AdapterDefinition;
|
|
17
|
+
agentArgs?: readonly string[];
|
|
18
|
+
unsupported?: Partial<Record<AcpLeg, string>>;
|
|
19
|
+
permissionPrompts?: { accepted: string; foreign: string };
|
|
20
|
+
startupModeAcknowledgement?: boolean;
|
|
21
|
+
modeAcknowledgement?: string;
|
|
22
|
+
quotaLabel?: string;
|
|
23
|
+
watchdogLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
/** No protocol is run at import; callers explicitly select supported fake protocol legs. */
|
|
26
|
+
export function registerAcpConformance(options: AcpConformanceOptions): void {
|
|
27
|
+
const { definition } = options, kind = definition.kind, fake = fakePaths.acpServer;
|
|
28
|
+
if (definition.transport !== "acp") throw new Error(kind + ": ACP conformance requires an ACP definition");
|
|
29
|
+
const reasons = Object.fromEntries(Object.entries(options.unsupported ?? {}).map(([leg, reason]) => [kind + ":" + leg, reason!]));
|
|
30
|
+
assertAcpLegEnrollment([definition], reasons);
|
|
31
|
+
if (!options.unsupported?.["native-mode-ack"] && !options.modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
|
|
32
|
+
// The catalog chooses every real registration or explicit unsupported outcome; no kind is filtered away.
|
|
33
|
+
const registrations: Record<AcpLeg, (kind: string) => void> = {
|
|
34
|
+
"native-approval-rpc": kind => {
|
|
35
|
+
test(`P4 ${kind}: real permission RPC approves exact hub identity once and asks on a lookalike`, async () => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-approval-")), asked: string[] = [], replies: string[] = [];
|
|
37
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? [])],
|
|
38
|
+
mcpServers: [{ name: "agent-hub", command: "unused", args: [], env: [] }],
|
|
39
|
+
autoApprove: title => title === "mcp__agent-hub__hub_task_list" || title === "mcp__agent-hub__hub_send",
|
|
40
|
+
onPermission: async request => { asked.push(request.title); return "no"; } } });
|
|
41
|
+
peer.onMessage = text => { replies.push(text); };
|
|
42
|
+
try {
|
|
43
|
+
await peer.start();
|
|
44
|
+
await peer.deliver([newEnvelope("user", options.permissionPrompts?.accepted ?? "PERMISSION HUBTOOL")]);
|
|
45
|
+
await until(() => replies.length === 1 && peer.state === "idle", "autoapproved permission settled");
|
|
46
|
+
expect(replies[0]).toEndWith("permission=yes"); expect(asked).toEqual([]);
|
|
47
|
+
await peer.deliver([newEnvelope("user", options.permissionPrompts?.foreign ?? "PERMISSION SPOOF")]);
|
|
48
|
+
await until(() => replies.length === 2 && peer.state === "idle", "lookalike permission settled");
|
|
49
|
+
expect(asked).toHaveLength(1); expect(replies[1]).toEndWith("permission=no");
|
|
50
|
+
await peer.setPermissionMode("never-ask"); expect(peer.permissionModeState).toBe("never-ask");
|
|
51
|
+
await peer.setPermissionMode("ask"); expect(peer.permissionModeState).toBe("ask");
|
|
52
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
53
|
+
}, 20_000);
|
|
54
|
+
},
|
|
55
|
+
"native-mode-ack": kind => {
|
|
56
|
+
const modeAcknowledgement = options.modeAcknowledgement;
|
|
57
|
+
if (!modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
|
|
58
|
+
test(`P4 ${kind}: unconfirmed mode change goes unknown/offline despite a late acknowledgement`, async () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-mode-")), pid = join(dir, "pid"), ack = join(dir, "ack");
|
|
60
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--ignore-term", "--mode-delay-ms", "200", "--mode-ack-record", ack, "--record-pid", pid],
|
|
61
|
+
permissionModeTimeoutMs: 40 } });
|
|
62
|
+
try {
|
|
63
|
+
// Qwen reconciles its default at startup; Kimi's unchanged ask default needs no initial request.
|
|
64
|
+
if (options.startupModeAcknowledgement) await expect(peer.start()).rejects.toThrow("unknown");
|
|
65
|
+
else { await peer.start(); await expect(peer.setPermissionMode("never-ask")).rejects.toThrow("unknown"); }
|
|
66
|
+
expect(peer.permissionModeState).toBe("unknown"); expect(peer.state).toBe("offline");
|
|
67
|
+
expect(readFileSync(ack, "utf8").trim()).toBe(modeAcknowledgement);
|
|
68
|
+
const owned = Number(readFileSync(pid, "utf8")), table = processTable();
|
|
69
|
+
expect(table).toBeDefined(); expect(table!.some(row => row.pid === owned || row.pgid === owned)).toBe(false);
|
|
70
|
+
await expect(peer.setPermissionMode("ask")).rejects.toThrow("unknown");
|
|
71
|
+
await expect(peer.deliver([newEnvelope("user", "must not start")])).rejects.toThrow("offline");
|
|
72
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
73
|
+
}, 20_000);
|
|
74
|
+
},
|
|
75
|
+
"native-quota": kind => {
|
|
76
|
+
test(`P4 ${options.quotaLabel ?? kind}: confirmed pre-activity quota pause settles failed_safe, not an ordinary failure`, async () => {
|
|
77
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-quota-")), receipts: DeliveryReceipt[] = [], order: string[] = [], failures: string[] = [];
|
|
78
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--quota-refusal"],
|
|
79
|
+
onQuotaRefusal: () => { order.push("paused"); return true; }, onTurnFailure: (_envs, reason) => { failures.push(reason); } } });
|
|
80
|
+
peer.onDelivery = receipt => { receipts.push(receipt); order.push(receipt.state); };
|
|
81
|
+
try {
|
|
82
|
+
await peer.start(); await peer.deliver([newEnvelope("user", "QUOTA_REFUSAL")], "quota");
|
|
83
|
+
await until(() => receipts.length > 0, "quota refusal receipt");
|
|
84
|
+
expect(order).toEqual(["paused", "failed_safe"]); expect(receipts[0]).toMatchObject({ id: "quota", state: "failed_safe", quota: true }); expect(failures).toEqual([]);
|
|
85
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
"watchdog-generation": kind => {
|
|
89
|
+
test(`P4 ${options.watchdogLabel ?? kind}: watchdog cancellation fences the consumed late native result by turn generation`, async () => {
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-watchdog-")), receipts: DeliveryReceipt[] = [], messages: string[] = [];
|
|
91
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fakePaths.acpWatchdog, ...(options.agentArgs ?? [])] } });
|
|
92
|
+
peer.onDelivery = receipt => { receipts.push(receipt); }; peer.onMessage = text => { messages.push(text); };
|
|
93
|
+
try {
|
|
94
|
+
await peer.start(); await peer.deliver([newEnvelope("user", "ACK_SLOW")], "old");
|
|
95
|
+
await until(() => receipts.some(receipt => receipt.state === "accepted"), "native acceptance before explicit expiry");
|
|
96
|
+
expire(peer);
|
|
97
|
+
await until(() => receipts.some(receipt => receipt.state === "needs_review"), "watchdog uncertainty");
|
|
98
|
+
// The fake responds to this mode request after sending the old prompt result on the same stdio stream.
|
|
99
|
+
await peer.setPermissionMode("ask");
|
|
100
|
+
expect(receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
|
|
101
|
+
expect(messages).toEqual([]); expect(peer.state).toBe("offline");
|
|
102
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const leg of ACP_LEGS) {
|
|
108
|
+
const reason = options.unsupported?.[leg];
|
|
109
|
+
if (reason !== undefined) test(`P4 ${kind}: ${leg} explicitly unsupported by its native transport`, () => {
|
|
110
|
+
expect(reason.trim()).not.toBeEmpty();
|
|
111
|
+
});
|
|
112
|
+
else registrations[leg](kind);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type { CodexPeer } from "../codex-appserver.ts";
|
|
6
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
7
|
+
import type { DeliveryReceipt } from "../../hub/peers.ts";
|
|
8
|
+
import { startFakeAppServer } from "./fakes/app-server.ts";
|
|
9
|
+
import { processTable } from "../../hub/child-process.ts";
|
|
10
|
+
import { until } from "./kit.ts";
|
|
11
|
+
import { fakePaths } from "./fake-paths.ts";
|
|
12
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
13
|
+
import { createFromDefinition, expire } from "./factories.ts";
|
|
14
|
+
|
|
15
|
+
/** Register only when called; native protocol and processes stay inside the test bodies. */
|
|
16
|
+
export function registerCodexConformance(definition: AdapterDefinition, { label = definition.displayName }: { label?: string } = {}): void {
|
|
17
|
+
if (definition.start.daemonPort !== "codex-appserver") throw new Error(definition.kind + ": conformance requires codex-appserver transport factory");
|
|
18
|
+
async function fixture(fake: Pick<ReturnType<typeof startFakeAppServer>, "url" | "requests" | "stop"> = startFakeAppServer()) {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-codex-")), messages: string[] = [], receipts: DeliveryReceipt[] = [], usage: boolean[] = [];
|
|
20
|
+
const peer = createFromDefinition<CodexPeer>(definition, { codex: { cwd: dir, upstreamUrl: fake.url, appPort: 0, proxyPort: 0, onUsage: (_reading, hard) => { usage.push(hard); } } });
|
|
21
|
+
peer.onMessage = text => { messages.push(text); }; peer.onDelivery = receipt => { receipts.push(receipt); };
|
|
22
|
+
await peer.start();
|
|
23
|
+
const seen: any[] = [], tui = new WebSocket(peer.proxyUrl);
|
|
24
|
+
tui.onmessage = event => seen.push(JSON.parse(String(event.data)));
|
|
25
|
+
await new Promise<void>((resolve, reject) => { tui.onopen = () => resolve(); tui.onerror = () => reject(new Error("fake Codex TUI connection")); });
|
|
26
|
+
tui.send(JSON.stringify({ id: 1, method: "initialize", params: { clientInfo: { name: "conformance-tui" } } }));
|
|
27
|
+
await until(() => seen.some(row => row.id === 1), "Codex initialize consumed");
|
|
28
|
+
tui.send(JSON.stringify({ id: 2, method: "thread/start", params: { approvalPolicy: "untrusted" } }));
|
|
29
|
+
await until(() => peer.state === "idle", "Codex thread adopted");
|
|
30
|
+
return { peer, fake, messages, receipts, usage, tui, seen, async close() { try { tui.close(); await peer.stop(); } finally { fake.stop(); rmSync(dir, { recursive: true, force: true }); } } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test(`P4 ${label}: permission overlays reach real turn RPC and ask restores the native baseline`, async () => {
|
|
34
|
+
const f = await fixture();
|
|
35
|
+
try {
|
|
36
|
+
for (const [mode, policy] of [["never-ask", "never"], ["ask", "untrusted"], ["ask-when-needed", "on-request"], ["ask", "untrusted"]] as const) {
|
|
37
|
+
await f.peer.setPermissionMode(mode);
|
|
38
|
+
const completed = f.messages.length;
|
|
39
|
+
await f.peer.deliver([newEnvelope("user", `mode ${mode}`)]);
|
|
40
|
+
await until(() => f.messages.length > completed && f.peer.state === "idle", "Codex policy turn settled");
|
|
41
|
+
expect(f.fake.requests.filter(row => row.method === "turn/start").at(-1).params.approvalPolicy).toBe(policy);
|
|
42
|
+
}
|
|
43
|
+
} finally { await f.close(); }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test(`P4 ${label}: rejected admission is failed_safe; accepted quota failure remains needs_review`, async () => {
|
|
47
|
+
const f = await fixture();
|
|
48
|
+
try {
|
|
49
|
+
const refusal = f.peer.deliver([newEnvelope("user", "REFUSE_TURN")], "refusal").catch(error => error);
|
|
50
|
+
await refusal;
|
|
51
|
+
await until(() => f.receipts.some(receipt => receipt.id === "refusal"), "definitive rejection receipt");
|
|
52
|
+
expect(f.receipts.find(receipt => receipt.id === "refusal")?.state).toBe("failed_safe");
|
|
53
|
+
await until(() => f.peer.state === "idle", "Codex refusal releases occupancy");
|
|
54
|
+
await f.peer.deliver([newEnvelope("user", "QUOTA")], "quota");
|
|
55
|
+
await until(() => f.receipts.some(receipt => receipt.id === "quota" && receipt.state === "needs_review"), "accepted quota failure");
|
|
56
|
+
expect(f.usage).toContain(true);
|
|
57
|
+
expect(f.receipts.filter(receipt => receipt.id === "quota").map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
|
|
58
|
+
expect(f.messages).toEqual([]);
|
|
59
|
+
} finally { await f.close(); }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test(`P4 ${label}: watchdog fences a late result after observed native acceptance`, async () => {
|
|
63
|
+
const f = await fixture();
|
|
64
|
+
try {
|
|
65
|
+
await f.peer.deliver([newEnvelope("user", "SLOW:100")], "old");
|
|
66
|
+
await until(() => f.receipts.some(receipt => receipt.state === "accepted"), "Codex native acceptance");
|
|
67
|
+
expire(f.peer);
|
|
68
|
+
// Forwarded turn/completed is a transport consumption barrier, not a guessed grace delay.
|
|
69
|
+
await until(() => f.seen.some(row => row.method === "turn/completed"), "late result consumed");
|
|
70
|
+
expect(f.messages).toEqual([]);
|
|
71
|
+
expect(f.receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
|
|
72
|
+
expect(f.peer.state).toBe("idle");
|
|
73
|
+
} finally { await f.close(); }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test(`P4 ${label}: stopping owns the launcher's group and its native app-server child`, async () => {
|
|
77
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-codex-group-")), bin = join(dir, "codex"), launcher = join(dir, "launcher.pid"), native = join(dir, "native.pid");
|
|
78
|
+
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
79
|
+
writeFileSync(bin, `#!/bin/sh\necho $$ > ${quote(launcher)}\ntrap '' TERM\n${quote(process.execPath)} ${quote(fakePaths.codexBin)} "$@" &\necho $! > ${quote(native)}\nwhile :; do sleep 1; done\n`, { mode: 0o755 });
|
|
80
|
+
const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
81
|
+
const appPort = reservation.port!; reservation.stop(true);
|
|
82
|
+
const peer = createFromDefinition<CodexPeer>(definition, { codex: { cwd: dir, bin, appPort, proxyPort: 0 } });
|
|
83
|
+
try {
|
|
84
|
+
await peer.start();
|
|
85
|
+
const leader = Number(readFileSync(launcher, "utf8")), child = Number(readFileSync(native, "utf8"));
|
|
86
|
+
const before = processTable(); expect(before).toBeDefined();
|
|
87
|
+
expect(before!.some(row => row.pid === child && row.pgid === leader)).toBe(true);
|
|
88
|
+
await peer.stop();
|
|
89
|
+
const after = processTable(); expect(after).toBeDefined();
|
|
90
|
+
expect(after!.some(row => row.pid === child || row.pid === leader || row.pgid === leader)).toBe(false);
|
|
91
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
92
|
+
}, 20_000);
|
|
93
|
+
|
|
94
|
+
/** Every release and the consuming-proxy barrier are controlled by the test, never by wall-clock sleeps. */
|
|
95
|
+
function controlledAppServer() {
|
|
96
|
+
const requests: any[] = [];
|
|
97
|
+
let socket: { send(data: string): unknown } | undefined;
|
|
98
|
+
const send = (message: unknown) => { if (!socket) throw new Error("controlled app-server not attached"); socket.send(JSON.stringify(message)); };
|
|
99
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: 0,
|
|
100
|
+
fetch(request, server) { return server.upgrade(request) ? undefined : new Response("not a websocket", { status: 400 }); },
|
|
101
|
+
websocket: { message(ws, raw) {
|
|
102
|
+
socket = ws;
|
|
103
|
+
const request = JSON.parse(String(raw)); requests.push(request);
|
|
104
|
+
if (request.method === "initialize") send({ id: request.id, result: { userAgent: "fake-codex/0.154.0" } });
|
|
105
|
+
else if (request.method === "thread/start" || request.method === "thread/resume") send({ id: request.id, result: { thread: { id: request.params.threadId ?? "th1" }, approvalPolicy: "untrusted" } });
|
|
106
|
+
else if (request.method === "account/rateLimits/read") send({ id: request.id, result: { rateLimits: {} } });
|
|
107
|
+
else if (request.method === "conformance/barrier") send({ id: request.id, result: {} });
|
|
108
|
+
} },
|
|
109
|
+
});
|
|
110
|
+
return { url: `ws://127.0.0.1:${server.port}`, requests, stop: () => server.stop(true), send,
|
|
111
|
+
note(method: string, params: Record<string, unknown>) { send({ method, params: { threadId: "th1", ...params } }); },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function barrier(f: Awaited<ReturnType<typeof fixture>>, id: number): Promise<void> {
|
|
115
|
+
f.tui.send(JSON.stringify({ id, method: "conformance/barrier", params: {} }));
|
|
116
|
+
await until(() => f.seen.some(row => row.id === id), "ordered controlled-app-server barrier consumed");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test(`P4 ${label}: accepted-before-start watchdog tombstone survives delayed native start until exact completion`, async () => {
|
|
120
|
+
const native = controlledAppServer(), f = await fixture(native);
|
|
121
|
+
try {
|
|
122
|
+
const delivery = f.peer.deliver([newEnvelope("user", "old accepted prompt")], "old-accepted");
|
|
123
|
+
await until(() => native.requests.some(row => row.method === "turn/start"), "native turn request observed");
|
|
124
|
+
const request = native.requests.find(row => row.method === "turn/start");
|
|
125
|
+
native.send({ id: request.id, result: { turn: { id: "retired" } } });
|
|
126
|
+
await delivery; // The fake has deliberately emitted no turn/started.
|
|
127
|
+
expire(f.peer);
|
|
128
|
+
native.note("turn/started", { turn: { id: "retired" } });
|
|
129
|
+
native.note("item/completed", { turnId: "retired", item: { id: "old-answer", type: "agentMessage", text: "must not publish" } });
|
|
130
|
+
await barrier(f, 50);
|
|
131
|
+
expect(f.peer.state).toBe("idle"); expect(f.messages).toEqual([]);
|
|
132
|
+
expect(f.receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
|
|
133
|
+
native.note("turn/completed", { turn: { id: "retired", status: "completed" } });
|
|
134
|
+
await barrier(f, 51);
|
|
135
|
+
expect(f.messages).toEqual([]); expect(f.receipts).toHaveLength(2);
|
|
136
|
+
} finally { await f.close(); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (const acceptance of [true, false]) test(`P4 ${label}: invalidated pending ${acceptance ? "acceptance" : "refusal"} never contaminates a newer personal turn or duplicates uncertainty`, async () => {
|
|
140
|
+
const native = controlledAppServer(), f = await fixture(native);
|
|
141
|
+
try {
|
|
142
|
+
const delivery = f.peer.deliver([newEnvelope("user", "cancelled audience")], "pending").catch(error => error as Error);
|
|
143
|
+
await until(() => native.requests.some(row => row.method === "turn/start"), "pending start observed");
|
|
144
|
+
const request = native.requests.find(row => row.method === "turn/start");
|
|
145
|
+
native.note("turn/started", { turn: { id: "old-pending" } });
|
|
146
|
+
await barrier(f, 60);
|
|
147
|
+
expire(f.peer);
|
|
148
|
+
expect((await delivery as Error).message).toContain("watchdog");
|
|
149
|
+
expect(f.receipts.map(receipt => receipt.state)).toEqual(["needs_review"]);
|
|
150
|
+
native.note("turn/started", { turn: { id: "personal-current" } });
|
|
151
|
+
await barrier(f, 61);
|
|
152
|
+
expect(f.peer.state).toBe("busy");
|
|
153
|
+
native.send(acceptance ? { id: request.id, result: { turn: { id: "old-pending" } } } : { id: request.id, error: { message: "turn rejected" } });
|
|
154
|
+
native.note("item/completed", { turnId: "old-pending", item: { id: "old-answer", type: "agentMessage", text: "stale answer" } });
|
|
155
|
+
native.note("turn/completed", { turn: { id: "old-pending", status: "completed" } });
|
|
156
|
+
await barrier(f, 62);
|
|
157
|
+
expect(f.peer.state).toBe("busy"); expect(f.messages).toEqual([]);
|
|
158
|
+
expect(f.receipts.map(receipt => receipt.state)).toEqual(["needs_review"]);
|
|
159
|
+
const replies: unknown[] = [];
|
|
160
|
+
f.peer.onMessage = (text, options) => { f.messages.push(text); replies.push(options?.inReplyTo); };
|
|
161
|
+
native.note("item/completed", { turnId: "personal-current", item: { id: "current-answer", type: "agentMessage", text: "current personal answer" } });
|
|
162
|
+
native.note("turn/completed", { turn: { id: "personal-current", status: "completed" } });
|
|
163
|
+
await barrier(f, 63);
|
|
164
|
+
expect(f.messages).toEqual(["current personal answer"]); expect(replies).toEqual([undefined]);
|
|
165
|
+
expect(f.peer.state).toBe("idle"); expect(f.receipts).toHaveLength(1);
|
|
166
|
+
} finally { await f.close(); }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test(`P4 ${label}: legacy unnamed turn works until cancellation makes its identity ambiguous`, async () => {
|
|
170
|
+
const native = controlledAppServer(), f = await fixture(native);
|
|
171
|
+
try {
|
|
172
|
+
native.note("turn/started", { turn: {} });
|
|
173
|
+
native.note("item/completed", { turnId: "legacy", item: { id: "legacy-answer", type: "agentMessage", text: "legacy answer" } });
|
|
174
|
+
native.note("turn/completed", { turn: { id: "legacy", status: "completed" } });
|
|
175
|
+
await barrier(f, 70);
|
|
176
|
+
expect(f.messages).toEqual(["legacy answer"]); expect(f.peer.state).toBe("idle");
|
|
177
|
+
native.note("turn/started", { turn: {} }); await barrier(f, 71);
|
|
178
|
+
expire(f.peer); expect(f.peer.state).toBe("offline");
|
|
179
|
+
native.note("turn/started", { turn: { id: "unattributable-late" } });
|
|
180
|
+
native.note("item/completed", { item: { id: "late", type: "agentMessage", text: "unattributable answer" } });
|
|
181
|
+
native.note("turn/completed", { turn: { id: "unattributable-late", status: "completed" } });
|
|
182
|
+
await barrier(f, 72);
|
|
183
|
+
expect(f.messages).toEqual(["legacy answer"]); expect(f.peer.state).toBe("offline");
|
|
184
|
+
// Adopting a verified session again is the explicit boundary that clears the attribution ceiling.
|
|
185
|
+
f.tui.send(JSON.stringify({ id: 73, method: "thread/resume", params: { threadId: "th1" } }));
|
|
186
|
+
await until(() => f.seen.some(row => row.id === 73), "session reattachment consumed");
|
|
187
|
+
expect(f.peer.state).toBe("idle");
|
|
188
|
+
} finally { await f.close(); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
|
+
|
|
3
|
+
/** Stable generic ACP legs; both actual test registration and enrollment validation consume this catalog. */
|
|
4
|
+
export const ACP_LEGS = Object.freeze(["native-approval-rpc", "native-mode-ack", "native-quota", "watchdog-generation"] as const);
|
|
5
|
+
export type AcpLeg = typeof ACP_LEGS[number];
|
|
6
|
+
/** No unsupported key or non-generic ACP engine can silently escape an executable catalog leg. */
|
|
7
|
+
export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[], unsupportedLegs: Readonly<Record<string, string>> = {}): void {
|
|
8
|
+
const acp = new Map(definitions.filter(definition => definition.transport === "acp").map(definition => [definition.kind, definition]));
|
|
9
|
+
for (const [key, reason] of Object.entries(unsupportedLegs)) {
|
|
10
|
+
const separator = key.lastIndexOf(":"), kind = key.slice(0, separator), leg = key.slice(separator + 1);
|
|
11
|
+
if (separator < 1 || !acp.has(kind) || !(ACP_LEGS as readonly string[]).includes(leg)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
|
|
12
|
+
if (typeof reason !== "string" || !reason.trim()) throw new Error(`${key}: unsupported ACP leg requires a concrete reason`);
|
|
13
|
+
}
|
|
14
|
+
for (const definition of acp.values()) if (definition.start.daemonPort !== "acp") {
|
|
15
|
+
for (const leg of ACP_LEGS) {
|
|
16
|
+
const key = `${definition.kind}:${leg}`;
|
|
17
|
+
if (!Object.hasOwn(unsupportedLegs, key)) throw new Error(`${key}: non-generic ACP engine requires an explicit skipped-leg reason`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function definitionIdentities(definitions: readonly AdapterDefinition[]): { id: string; definition: AdapterDefinition }[] {
|
|
22
|
+
return definitions.flatMap(definition => [{ id: definition.kind, definition },
|
|
23
|
+
...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : [])]);
|
|
24
|
+
}
|
|
25
|
+
export function assertEnrollment(definitions: readonly AdapterDefinition[], enrolled: readonly string[], unsupported: Readonly<Record<string, string>>, unsupportedLegs: Readonly<Record<string, string>> = {}, coherence?: (definition: AdapterDefinition) => void): void {
|
|
26
|
+
assertAcpLegEnrollment(definitions, unsupportedLegs);
|
|
27
|
+
const rows = definitionIdentities(definitions), identities = new Set(rows.map(row => row.id));
|
|
28
|
+
if (identities.size !== rows.length) throw new Error("duplicate conformance definition identity");
|
|
29
|
+
if (new Set(enrolled).size !== enrolled.length) throw new Error("duplicate conformance enrollment");
|
|
30
|
+
for (const id of enrolled) if (!identities.has(id)) throw new Error(`${id}: enrolled fixture has no registry definition`);
|
|
31
|
+
for (const [id, reason] of Object.entries(unsupported)) {
|
|
32
|
+
if (!identities.has(id)) throw new Error(`${id}: unsupported reason has no registry definition`);
|
|
33
|
+
if (!reason.trim()) throw new Error(`${id}: unsupported needs a concrete reason`);
|
|
34
|
+
if (enrolled.includes(id)) throw new Error(`${id}: cannot be both enrolled and unsupported`);
|
|
35
|
+
}
|
|
36
|
+
for (const { id, definition } of rows) {
|
|
37
|
+
try { coherence?.(definition); }
|
|
38
|
+
catch (error) { throw new Error(`${id}: ${(error as Error).message}`); }
|
|
39
|
+
if (!enrolled.includes(id) && !Object.hasOwn(unsupported, id)) throw new Error(`${id}: no conformance fixture or unsupported reason`);
|
|
40
|
+
if (definition.transport === "acp" && !enrolled.includes(id)) throw new Error(`${id}: every ACP definition must run the ACP legs`);
|
|
41
|
+
if (id.endsWith(":reviewer") && !definition.reviewer) throw new Error(`${id}: reviewer variant lacks reviewer policy`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AdapterDefinition, CreateContext, DaemonPorts } from "../registry-types.ts";
|
|
2
|
+
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** The supplied definition creates its actual adapter, with only this fixture's transport options. */
|
|
6
|
+
export function createFromDefinition<T extends PeerAdapter>(definition: AdapterDefinition, daemon: DaemonPorts): T {
|
|
7
|
+
if (!definition.start.create) throw new Error(`${definition.kind}: no driven native factory; use its external-hello fixture`);
|
|
8
|
+
const options = Object.values(daemon)[0] as { cwd: string; stateDir?: string; mcpServers?: CreateContext["mcpServers"] };
|
|
9
|
+
return definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
|
|
10
|
+
kindConfig: {}, permission: "ask", mcpServers: options.mcpServers ?? [], daemon }) as T;
|
|
11
|
+
}
|
|
12
|
+
/** Explicit watchdog seam on the factory-created implementation, replacing direct test subclasses. */
|
|
13
|
+
export function expire(peer: PeerAdapter): void {
|
|
14
|
+
const watched = peer as PeerAdapter & { onWatchdog(): void };
|
|
15
|
+
if (typeof watched.onWatchdog !== "function") throw new Error(`${peer.id}: no watchdog seam`);
|
|
16
|
+
watched.onWatchdog();
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/** Shipped assets are resolved without importing or executing their native protocol loops. */
|
|
4
|
+
export const fakePaths = Object.freeze({
|
|
5
|
+
acpServer: fileURLToPath(new URL("./fakes/acp-server.ts", import.meta.url)),
|
|
6
|
+
appServer: fileURLToPath(new URL("./fakes/app-server.ts", import.meta.url)),
|
|
7
|
+
modelServer: fileURLToPath(new URL("./fakes/model-server.ts", import.meta.url)),
|
|
8
|
+
piRpc: fileURLToPath(new URL("./fakes/pi-rpc.ts", import.meta.url)),
|
|
9
|
+
jcodeAgent: fileURLToPath(new URL("./fakes/jcode-agent.ts", import.meta.url)),
|
|
10
|
+
codexBin: fileURLToPath(new URL("./fakes/codex-bin.ts", import.meta.url)),
|
|
11
|
+
acpWatchdog: fileURLToPath(new URL("./fakes/acp-watchdog-agent.ts", import.meta.url)),
|
|
12
|
+
});
|