@selesai/code 0.9.6 → 0.9.8
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 +13 -0
- package/dist/cli/credential-print.js +4 -4
- package/dist/cli.js +0 -0
- package/dist/core/agent-session-runtime.js +1 -4
- package/dist/core/agent-session.d.ts +2 -0
- package/dist/core/agent-session.js +26 -0
- package/dist/core/compaction/branch-summarization.js +30 -30
- package/dist/core/compaction/compaction.js +81 -81
- package/dist/core/compaction/utils.js +2 -2
- package/dist/core/export-html/template.css +1066 -1066
- package/dist/core/export-html/template.html +55 -55
- package/dist/core/export-html/template.js +1864 -1864
- package/dist/core/export-html/vendor/highlight.min.js +1212 -1212
- package/dist/core/export-html/vendor/marked.min.js +78 -78
- package/dist/core/handoff.d.ts +11 -0
- package/dist/core/handoff.js +49 -0
- package/dist/core/messages.js +7 -7
- package/dist/extensions/context-compaction-reminder.test.ts +82 -82
- package/dist/extensions/context-compaction-reminder.ts +28 -28
- package/dist/extensions/handoff-new.ts +14 -75
- package/dist/extensions/pi-intercom/LICENSE +21 -21
- package/dist/extensions/pi-intercom/broker/client.test.ts +83 -83
- package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -387
- package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -114
- package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -153
- package/dist/extensions/pi-intercom/broker/paths.ts +134 -134
- package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -34
- package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -21
- package/dist/extensions/pi-intercom/cwd.test.ts +40 -40
- package/dist/extensions/pi-intercom/cwd.ts +31 -31
- package/dist/extensions/pi-intercom/extension-api.ts +44 -44
- package/dist/extensions/pi-intercom/format-context.test.ts +31 -31
- package/dist/extensions/pi-intercom/format-context.ts +32 -32
- package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -66
- package/dist/extensions/pi-intercom/ui/compose.ts +143 -143
- package/dist/extensions/pi-intercom/ui/session-list.ts +166 -166
- package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +286 -286
- package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +108 -108
- package/dist/extensions/pi-powerline-footer/separators.ts +57 -57
- package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +47 -47
- package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -39
- package/dist/extensions/pi-powerline-footer/theme.example.json +24 -24
- package/dist/extensions/pi-powerline-footer/theme.json +12 -12
- package/dist/extensions/pi-powerline-footer/tps.ts +345 -345
- package/dist/extensions/pi-rewind-hook/README.md +245 -245
- package/dist/extensions/pi-rewind-hook/index.ts +1445 -1445
- package/dist/extensions/pi-rewind-hook/package.json +29 -29
- package/dist/extensions/pi-subagents/install.mjs +0 -0
- package/dist/extensions/pi-web-agent/package.json +31 -31
- package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -205
- package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -136
- package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -152
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -25
- package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -28
- package/dist/extensions/pi-web-agent/src/changelog-notice.ts +136 -136
- package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -946
- package/dist/extensions/pi-web-agent/src/extension.ts +126 -126
- package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -118
- package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -199
- package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -100
- package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -117
- package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -67
- package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -60
- package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -50
- package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -52
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -105
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -45
- package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -28
- package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -47
- package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -376
- package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -64
- package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -181
- package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -101
- package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -81
- package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +210 -210
- package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -75
- package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -61
- package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -54
- package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -41
- package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -20
- package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -63
- package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -114
- package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -72
- package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -96
- package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -71
- package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -31
- package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -31
- package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -157
- package/dist/extensions/pi-web-agent/src/types.ts +88 -88
- package/dist/extensions/ponytail/package.json +8 -8
- package/dist/extensions/question/batch.ts +103 -103
- package/dist/extensions/question/constants.ts +30 -30
- package/dist/extensions/question/helpers.ts +58 -58
- package/dist/extensions/question/navigation.ts +14 -14
- package/dist/extensions/question/package.json +19 -19
- package/dist/extensions/question/schemas.ts +43 -43
- package/dist/extensions/question/selection-mode.ts +46 -46
- package/dist/extensions/question/shortcuts.ts +44 -44
- package/dist/extensions/question/types.ts +132 -132
- package/dist/extensions/test-resolve-hook-impl.mjs +6 -6
- package/dist/extensions/test-resolve-hook.mjs +6 -6
- package/dist/extensions/web-agent-onboarding.ts +222 -222
- package/dist/extensions/workflow/package.json +17 -17
- package/dist/modes/rpc/rpc-mode.js +24 -0
- package/dist/modes/rpc/rpc-types.d.ts +19 -0
- package/dist/package-manager-cli.js +71 -71
- package/dist/rpc-entry.js +0 -0
- package/dist/skills/agent-browser/SKILL.md +52 -52
- package/dist/skills/batch-grill-me/SKILL.md +19 -19
- package/dist/skills/grill-me/SKILL.md +10 -10
- package/dist/skills/handoff/SKILL.md +16 -16
- package/dist/skills/handoff-text/SKILL.md +14 -14
- package/dist/skills/implanger/SKILL.md +69 -69
- package/dist/skills/improve-codebase/REFERENCE.md +78 -78
- package/dist/skills/improve-codebase/SKILL.md +178 -178
- package/dist/skills/planger/SKILL.md +166 -166
- package/dist/skills/ponytail/SKILL.md +116 -116
- package/dist/skills/ponytail-audit/SKILL.md +42 -42
- package/dist/skills/ponytail-debt/SKILL.md +45 -45
- package/dist/skills/ponytail-gain/SKILL.md +51 -51
- package/dist/skills/ponytail-help/SKILL.md +70 -70
- package/dist/skills/ponytail-review/SKILL.md +58 -58
- package/dist/skills/selesai-handoff/SKILL.md +20 -20
- package/dist/skills/workflow-creation/SKILL.md +73 -73
- package/dist/themes/powerline-footer/theme.json +33 -33
- package/docs/compaction.md +396 -396
- package/docs/containerization.md +111 -111
- package/docs/development.md +71 -71
- package/docs/docs.json +164 -164
- package/docs/environment-variables.md +86 -86
- package/docs/index.md +83 -83
- package/docs/json.md +82 -82
- package/docs/models.md +502 -502
- package/docs/packages.md +227 -227
- package/docs/plans/subagent-delegation/phase-0-correctness.md +264 -264
- package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +485 -485
- package/docs/plans/subagent-delegation/phase-2-context-controls.md +281 -281
- package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +361 -361
- package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +380 -380
- package/docs/prompt-templates.md +95 -95
- package/docs/providers.md +293 -293
- package/docs/sdk.md +1143 -1143
- package/docs/security.md +59 -59
- package/docs/session-format.md +414 -414
- package/docs/sessions.md +145 -145
- package/docs/shared-host-extensions.md +109 -109
- package/docs/shell-aliases.md +13 -13
- package/docs/skills.md +231 -231
- package/docs/terminal-setup.md +142 -142
- package/docs/termux.md +127 -127
- package/docs/themes.md +295 -295
- package/docs/tmux.md +63 -63
- package/docs/tui.md +927 -927
- package/docs/windows.md +17 -17
- package/examples/README.md +25 -25
- package/examples/extensions/README.md +211 -211
- package/examples/extensions/auto-commit-on-exit.ts +49 -49
- package/examples/extensions/bash-spawn-hook.ts +30 -30
- package/examples/extensions/bookmark.ts +50 -50
- package/examples/extensions/border-status-editor.ts +150 -150
- package/examples/extensions/built-in-tool-renderer.ts +249 -249
- package/examples/extensions/claude-rules.ts +86 -86
- package/examples/extensions/commands.ts +72 -72
- package/examples/extensions/confirm-destructive.ts +59 -59
- package/examples/extensions/custom-compaction.ts +130 -130
- package/examples/extensions/custom-footer.ts +64 -64
- package/examples/extensions/custom-header.ts +73 -73
- package/examples/extensions/custom-provider-anthropic/index.ts +610 -610
- package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -24
- package/examples/extensions/custom-provider-anthropic/package.json +19 -19
- package/examples/extensions/custom-provider-gitlab-duo/index.ts +404 -404
- package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -16
- package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -82
- package/examples/extensions/dirty-repo-guard.ts +56 -56
- package/examples/extensions/doom-overlay/README.md +46 -46
- package/examples/extensions/doom-overlay/doom/build/doom.js +21 -21
- package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
- package/examples/extensions/doom-overlay/doom/build.sh +152 -152
- package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -72
- package/examples/extensions/doom-overlay/doom-component.ts +132 -132
- package/examples/extensions/doom-overlay/doom-engine.ts +173 -173
- package/examples/extensions/doom-overlay/doom-keys.ts +104 -104
- package/examples/extensions/doom-overlay/index.ts +74 -74
- package/examples/extensions/doom-overlay/wad-finder.ts +51 -51
- package/examples/extensions/dynamic-resources/SKILL.md +8 -8
- package/examples/extensions/dynamic-resources/dynamic.json +79 -79
- package/examples/extensions/dynamic-resources/dynamic.md +5 -5
- package/examples/extensions/dynamic-resources/index.ts +15 -15
- package/examples/extensions/dynamic-tools.ts +74 -74
- package/examples/extensions/event-bus.ts +43 -43
- package/examples/extensions/file-trigger.ts +41 -41
- package/examples/extensions/git-checkpoint.ts +53 -53
- package/examples/extensions/git-merge-and-resolve.ts +115 -115
- package/examples/extensions/github-issue-autocomplete.ts +185 -185
- package/examples/extensions/gondolin/index.ts +531 -531
- package/examples/extensions/gondolin/package-lock.json +185 -185
- package/examples/extensions/gondolin/package.json +19 -19
- package/examples/extensions/handoff.ts +199 -199
- package/examples/extensions/hello.ts +26 -26
- package/examples/extensions/hidden-thinking-label.ts +53 -53
- package/examples/extensions/inline-bash.ts +94 -94
- package/examples/extensions/input-transform-streaming.ts +39 -39
- package/examples/extensions/input-transform.ts +43 -43
- package/examples/extensions/interactive-shell.ts +196 -196
- package/examples/extensions/mac-system-theme.ts +47 -47
- package/examples/extensions/message-renderer.ts +59 -59
- package/examples/extensions/minimal-mode.ts +426 -426
- package/examples/extensions/modal-editor.ts +85 -85
- package/examples/extensions/model-status.ts +31 -31
- package/examples/extensions/notify.ts +55 -55
- package/examples/extensions/overlay-qa-tests.ts +1450 -1450
- package/examples/extensions/overlay-test.ts +153 -153
- package/examples/extensions/permission-gate.ts +34 -34
- package/examples/extensions/pirate.ts +47 -47
- package/examples/extensions/plan-mode/README.md +66 -66
- package/examples/extensions/plan-mode/index.ts +390 -390
- package/examples/extensions/plan-mode/utils.ts +168 -168
- package/examples/extensions/preset.ts +436 -436
- package/examples/extensions/project-trust.ts +64 -64
- package/examples/extensions/prompt-customizer.ts +97 -97
- package/examples/extensions/protected-paths.ts +30 -30
- package/examples/extensions/provider-payload.ts +18 -18
- package/examples/extensions/qna.ts +122 -122
- package/examples/extensions/question.ts +285 -285
- package/examples/extensions/questionnaire.ts +448 -448
- package/examples/extensions/rainbow-editor.ts +88 -88
- package/examples/extensions/reload-runtime.ts +37 -37
- package/examples/extensions/rpc-demo.ts +118 -118
- package/examples/extensions/sandbox/index.ts +321 -321
- package/examples/extensions/sandbox/package-lock.json +92 -92
- package/examples/extensions/sandbox/package.json +19 -19
- package/examples/extensions/send-user-message.ts +97 -97
- package/examples/extensions/session-name.ts +27 -27
- package/examples/extensions/shutdown-command.ts +63 -63
- package/examples/extensions/snake.ts +343 -343
- package/examples/extensions/space-invaders.ts +560 -560
- package/examples/extensions/ssh.ts +220 -220
- package/examples/extensions/status-line.ts +32 -32
- package/examples/extensions/structured-output.ts +65 -65
- package/examples/extensions/subagent/README.md +175 -175
- package/examples/extensions/subagent/agents/planner.md +37 -37
- package/examples/extensions/subagent/agents/reviewer.md +35 -35
- package/examples/extensions/subagent/agents/scout.md +50 -50
- package/examples/extensions/subagent/agents/worker.md +24 -24
- package/examples/extensions/subagent/agents.ts +126 -126
- package/examples/extensions/subagent/index.ts +1015 -1015
- package/examples/extensions/subagent/prompts/implement-and-review.md +10 -10
- package/examples/extensions/subagent/prompts/implement.md +10 -10
- package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -9
- package/examples/extensions/summarize.ts +209 -209
- package/examples/extensions/system-prompt-header.ts +17 -17
- package/examples/extensions/tic-tac-toe.ts +1008 -1008
- package/examples/extensions/timed-confirm.ts +70 -70
- package/examples/extensions/titlebar-spinner.ts +58 -58
- package/examples/extensions/todo.ts +297 -297
- package/examples/extensions/tool-override.ts +144 -144
- package/examples/extensions/tools.ts +146 -146
- package/examples/extensions/trigger-compact.ts +50 -50
- package/examples/extensions/truncated-tool.ts +195 -195
- package/examples/extensions/widget-placement.ts +9 -9
- package/examples/extensions/with-deps/index.ts +32 -32
- package/examples/extensions/with-deps/package-lock.json +31 -31
- package/examples/extensions/with-deps/package.json +22 -22
- package/examples/extensions/working-indicator.ts +123 -123
- package/examples/extensions/working-message-test.ts +25 -25
- package/examples/rpc-extension-ui.ts +632 -632
- package/examples/sdk/01-minimal.ts +26 -26
- package/examples/sdk/02-custom-model.ts +53 -53
- package/examples/sdk/03-custom-prompt.ts +75 -75
- package/examples/sdk/04-skills.ts +55 -55
- package/examples/sdk/05-tools.ts +48 -48
- package/examples/sdk/06-extensions.ts +99 -99
- package/examples/sdk/07-context-files.ts +47 -47
- package/examples/sdk/08-prompt-templates.ts +51 -51
- package/examples/sdk/09-api-keys-and-oauth.ts +52 -52
- package/examples/sdk/10-settings.ts +53 -53
- package/examples/sdk/11-sessions.ts +52 -52
- package/examples/sdk/12-full-control.ts +79 -79
- package/examples/sdk/13-session-runtime.ts +67 -67
- package/examples/sdk/README.md +144 -144
- package/package.json +1 -1
|
@@ -1,387 +1,387 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
|
-
import { once } from "node:events";
|
|
4
|
-
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import test from "node:test";
|
|
8
|
-
import type { BrokerMessage, SessionRegistration } from "../types.ts";
|
|
9
|
-
import { IntercomClient } from "./client.ts";
|
|
10
|
-
import { ExtensionStateManager } from "./extension-state.ts";
|
|
11
|
-
import { getTsxCliPath } from "./spawn.ts";
|
|
12
|
-
|
|
13
|
-
const repoDir = process.cwd();
|
|
14
|
-
|
|
15
|
-
function registration(name: string, startedAt: number, ownerEligible?: boolean): SessionRegistration {
|
|
16
|
-
return {
|
|
17
|
-
name,
|
|
18
|
-
cwd: "/test",
|
|
19
|
-
model: "test-model",
|
|
20
|
-
pid: process.pid,
|
|
21
|
-
startedAt,
|
|
22
|
-
lastActivity: Date.now(),
|
|
23
|
-
...(ownerEligible === undefined
|
|
24
|
-
? {}
|
|
25
|
-
: { extensions: [{ namespace: "test/v1", ownerEligible }] }),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function waitFor(
|
|
30
|
-
messages: BrokerMessage[],
|
|
31
|
-
predicate: (message: BrokerMessage) => boolean,
|
|
32
|
-
timeoutMs = 3000,
|
|
33
|
-
): Promise<BrokerMessage> {
|
|
34
|
-
const deadline = Date.now() + timeoutMs;
|
|
35
|
-
while (Date.now() < deadline) {
|
|
36
|
-
const message = messages.find(predicate);
|
|
37
|
-
if (message) return message;
|
|
38
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
39
|
-
}
|
|
40
|
-
throw new Error("Timed out waiting for broker message");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function startBroker(agentDir: string): Promise<ChildProcessWithoutNullStreams> {
|
|
44
|
-
const broker = spawn(
|
|
45
|
-
process.execPath,
|
|
46
|
-
[getTsxCliPath(repoDir), path.join(repoDir, "broker", "broker.ts")],
|
|
47
|
-
{
|
|
48
|
-
cwd: repoDir,
|
|
49
|
-
env: { ...process.env, SELESAI_CODING_AGENT_DIR: agentDir },
|
|
50
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
-
},
|
|
52
|
-
);
|
|
53
|
-
const ready = new Promise<void>((resolve, reject) => {
|
|
54
|
-
const timeout = setTimeout(() => reject(new Error("Broker startup timed out")), 10_000);
|
|
55
|
-
broker.stdout.on("data", (chunk: Buffer) => {
|
|
56
|
-
if (chunk.toString().includes("Intercom broker started")) {
|
|
57
|
-
clearTimeout(timeout);
|
|
58
|
-
resolve();
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
broker.once("exit", (code, signal) => {
|
|
62
|
-
clearTimeout(timeout);
|
|
63
|
-
reject(new Error(`Broker exited before startup (${code ?? signal})`));
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
await ready;
|
|
67
|
-
return broker;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function stopBroker(broker: ChildProcessWithoutNullStreams): Promise<void> {
|
|
71
|
-
if (broker.exitCode !== null) return;
|
|
72
|
-
broker.kill("SIGTERM");
|
|
73
|
-
await once(broker, "exit");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
test("extension bus negotiates, routes, elects an owner, and persists state", { concurrency: false, timeout: 30_000 }, async () => {
|
|
77
|
-
const agentDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-extension-"));
|
|
78
|
-
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
79
|
-
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
80
|
-
const broker = await startBroker(agentDir);
|
|
81
|
-
const clients: IntercomClient[] = [];
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const invalidNamespace = new IntercomClient();
|
|
85
|
-
await assert.rejects(
|
|
86
|
-
invalidNamespace.connect({
|
|
87
|
-
...registration("invalid", Date.now()),
|
|
88
|
-
extensions: [{ namespace: "Invalid Namespace", ownerEligible: true }],
|
|
89
|
-
}),
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const tooManyExtensions = new IntercomClient();
|
|
93
|
-
await assert.rejects(
|
|
94
|
-
tooManyExtensions.connect({
|
|
95
|
-
...registration("too-many", Date.now()),
|
|
96
|
-
extensions: Array.from({ length: 33 }, (_, index) => ({
|
|
97
|
-
namespace: `test-${index}/v1`,
|
|
98
|
-
ownerEligible: true,
|
|
99
|
-
})),
|
|
100
|
-
}),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const owner = new IntercomClient();
|
|
104
|
-
const peer = new IntercomClient();
|
|
105
|
-
const legacy = new IntercomClient();
|
|
106
|
-
const late = new IntercomClient();
|
|
107
|
-
const peerOnlyA = new IntercomClient();
|
|
108
|
-
const peerOnlyB = new IntercomClient();
|
|
109
|
-
clients.push(owner, peer, legacy, late, peerOnlyA, peerOnlyB);
|
|
110
|
-
|
|
111
|
-
const ownerMessages: BrokerMessage[] = [];
|
|
112
|
-
const peerMessages: BrokerMessage[] = [];
|
|
113
|
-
const legacyMessages: BrokerMessage[] = [];
|
|
114
|
-
const lateMessages: BrokerMessage[] = [];
|
|
115
|
-
const peerOnlyBMessages: BrokerMessage[] = [];
|
|
116
|
-
const ownerErrors: Error[] = [];
|
|
117
|
-
const legacyErrors: Error[] = [];
|
|
118
|
-
owner.onBrokerMessage((message) => ownerMessages.push(message));
|
|
119
|
-
peer.onBrokerMessage((message) => peerMessages.push(message));
|
|
120
|
-
legacy.onBrokerMessage((message) => legacyMessages.push(message));
|
|
121
|
-
late.onBrokerMessage((message) => lateMessages.push(message));
|
|
122
|
-
peerOnlyB.onBrokerMessage((message) => peerOnlyBMessages.push(message));
|
|
123
|
-
owner.on("error", (error) => ownerErrors.push(error));
|
|
124
|
-
peer.on("error", () => {});
|
|
125
|
-
legacy.on("error", (error) => legacyErrors.push(error));
|
|
126
|
-
late.on("error", () => {});
|
|
127
|
-
peerOnlyA.on("error", () => {});
|
|
128
|
-
peerOnlyB.on("error", () => {});
|
|
129
|
-
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
await owner.connect(registration("owner", now - 1000, true), "owner-id");
|
|
132
|
-
|
|
133
|
-
const invalidReplacement = new IntercomClient();
|
|
134
|
-
await assert.rejects(invalidReplacement.connect({
|
|
135
|
-
...registration("invalid-replacement", now),
|
|
136
|
-
extensions: [{ namespace: "Invalid Namespace", ownerEligible: true }],
|
|
137
|
-
}, "owner-id"));
|
|
138
|
-
assert.equal((await owner.listSessions()).some((session) => session.id === "owner-id"), true);
|
|
139
|
-
await invalidReplacement.disconnect().catch(() => undefined);
|
|
140
|
-
|
|
141
|
-
await peer.connect(registration("peer", now, false), "peer-id");
|
|
142
|
-
await legacy.connect(registration("legacy", now), "legacy-id");
|
|
143
|
-
await late.connect(registration("late", 0), "late-id");
|
|
144
|
-
await peerOnlyA.connect({
|
|
145
|
-
...registration("peer-only-a", now + 2),
|
|
146
|
-
extensions: [{ namespace: "peer-only/v1", ownerEligible: false }],
|
|
147
|
-
}, "peer-only-a-id");
|
|
148
|
-
await peerOnlyB.connect({
|
|
149
|
-
...registration("peer-only-b", now + 3),
|
|
150
|
-
extensions: [{ namespace: "peer-only/v1", ownerEligible: false }],
|
|
151
|
-
}, "peer-only-b-id");
|
|
152
|
-
|
|
153
|
-
assert.equal(owner.supportsFeature("extension-bus-v1"), true);
|
|
154
|
-
assert.equal(legacy.supportsFeature("extension-bus-v1"), true, "new broker advertises support even to clients without capabilities");
|
|
155
|
-
|
|
156
|
-
const ownerEvent = await waitFor(ownerMessages, (message) => message.type === "extension_owner");
|
|
157
|
-
assert.equal(ownerEvent.type, "extension_owner");
|
|
158
|
-
assert.equal(ownerEvent.ownerId, "owner-id");
|
|
159
|
-
assert.ok(ownerEvent.ownerEpoch);
|
|
160
|
-
const peerOwnerEvent = await waitFor(peerMessages, (message) => message.type === "extension_owner");
|
|
161
|
-
assert.equal(peerOwnerEvent.type, "extension_owner");
|
|
162
|
-
assert.equal(peerOwnerEvent.ownerId, "owner-id");
|
|
163
|
-
|
|
164
|
-
late.updateExtensionCapabilities([{ namespace: "test/v1", ownerEligible: true }]);
|
|
165
|
-
const lateOwnerEvent = await waitFor(lateMessages, (message) => message.type === "extension_owner");
|
|
166
|
-
assert.equal(lateOwnerEvent.type, "extension_owner");
|
|
167
|
-
assert.equal(lateOwnerEvent.ownerId, "owner-id", "backdated client must not seize namespace ownership");
|
|
168
|
-
late.updateExtensionCapabilities([{ namespace: "test/v1", ownerEligible: false }]);
|
|
169
|
-
|
|
170
|
-
legacy.updateExtensionCapabilities([{ namespace: "other/v1", ownerEligible: false }]);
|
|
171
|
-
legacy.sendExtensionMessage({
|
|
172
|
-
type: "extension_publish",
|
|
173
|
-
namespace: "test/v1",
|
|
174
|
-
audience: "capable",
|
|
175
|
-
payload: { unauthorized: true },
|
|
176
|
-
});
|
|
177
|
-
const unauthorizedDeadline = Date.now() + 3000;
|
|
178
|
-
while (Date.now() < unauthorizedDeadline && legacyErrors.length === 0) {
|
|
179
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
-
}
|
|
181
|
-
assert.match(legacyErrors[0]?.message ?? "", /does not have capability for this namespace/);
|
|
182
|
-
legacy.sendExtensionMessage({
|
|
183
|
-
type: "extension_state_commit",
|
|
184
|
-
namespace: "test/v1",
|
|
185
|
-
ownerEpoch: "unauthorized",
|
|
186
|
-
expectedRevision: 0,
|
|
187
|
-
payload: {},
|
|
188
|
-
});
|
|
189
|
-
const unauthorizedState = await waitFor(
|
|
190
|
-
legacyMessages,
|
|
191
|
-
(message) => message.type === "extension_state_result",
|
|
192
|
-
);
|
|
193
|
-
assert.equal(unauthorizedState.type, "extension_state_result");
|
|
194
|
-
assert.equal(unauthorizedState.committed, false);
|
|
195
|
-
assert.match(unauthorizedState.reason ?? "", /does not have capability for this namespace/);
|
|
196
|
-
|
|
197
|
-
owner.sendExtensionMessage({
|
|
198
|
-
type: "extension_publish",
|
|
199
|
-
namespace: "test/v1",
|
|
200
|
-
audience: "capable",
|
|
201
|
-
payload: "x".repeat(16 * 1024),
|
|
202
|
-
});
|
|
203
|
-
const oversizedDeadline = Date.now() + 3000;
|
|
204
|
-
while (Date.now() < oversizedDeadline && !ownerErrors.some((error) => /16 KiB/.test(error.message))) {
|
|
205
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
206
|
-
}
|
|
207
|
-
assert.equal(ownerErrors.some((error) => /16 KiB/.test(error.message)), true);
|
|
208
|
-
ownerErrors.length = 0;
|
|
209
|
-
|
|
210
|
-
peerOnlyA.sendExtensionMessage({
|
|
211
|
-
type: "extension_publish",
|
|
212
|
-
namespace: "peer-only/v1",
|
|
213
|
-
audience: "capable",
|
|
214
|
-
payload: { kind: "peer-broadcast" },
|
|
215
|
-
});
|
|
216
|
-
const peerBroadcast = await waitFor(peerOnlyBMessages, (message) => message.type === "extension_message");
|
|
217
|
-
assert.equal(peerBroadcast.type, "extension_message");
|
|
218
|
-
assert.equal(peerBroadcast.ownerId, undefined);
|
|
219
|
-
assert.equal(peerBroadcast.ownerEpoch, undefined);
|
|
220
|
-
assert.deepEqual(peerBroadcast.payload, { kind: "peer-broadcast" });
|
|
221
|
-
|
|
222
|
-
peer.sendExtensionMessage({
|
|
223
|
-
type: "extension_publish",
|
|
224
|
-
namespace: "test/v1",
|
|
225
|
-
audience: "owner",
|
|
226
|
-
payload: { kind: "owner-target" },
|
|
227
|
-
});
|
|
228
|
-
const ownerTarget = await waitFor(
|
|
229
|
-
ownerMessages,
|
|
230
|
-
(message) => message.type === "extension_message"
|
|
231
|
-
&& (message.payload as { kind?: string }).kind === "owner-target",
|
|
232
|
-
);
|
|
233
|
-
assert.equal(ownerTarget.type, "extension_message");
|
|
234
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
235
|
-
assert.equal(
|
|
236
|
-
peerMessages.some((message) => message.type === "extension_message"
|
|
237
|
-
&& (message.payload as { kind?: string }).kind === "owner-target"),
|
|
238
|
-
false,
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
owner.sendExtensionMessage({
|
|
242
|
-
type: "extension_publish",
|
|
243
|
-
namespace: "test/v1",
|
|
244
|
-
audience: "capable",
|
|
245
|
-
ownerOnly: true,
|
|
246
|
-
ownerEpoch: ownerEvent.ownerEpoch,
|
|
247
|
-
payload: { kind: "assignment" },
|
|
248
|
-
});
|
|
249
|
-
const received = await waitFor(peerMessages, (message) => message.type === "extension_message");
|
|
250
|
-
assert.equal(received.type, "extension_message");
|
|
251
|
-
assert.deepEqual(received.payload, { kind: "assignment" });
|
|
252
|
-
const lateReceived = await waitFor(lateMessages, (message) => message.type === "extension_message");
|
|
253
|
-
assert.equal(lateReceived.type, "extension_message");
|
|
254
|
-
assert.deepEqual(lateReceived.payload, { kind: "assignment" });
|
|
255
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
256
|
-
assert.equal(legacyMessages.some((message) => message.type === "extension_message"), false);
|
|
257
|
-
|
|
258
|
-
owner.sendExtensionMessage({
|
|
259
|
-
type: "extension_state_commit",
|
|
260
|
-
namespace: "test/v1",
|
|
261
|
-
ownerEpoch: ownerEvent.ownerEpoch!,
|
|
262
|
-
expectedRevision: 0,
|
|
263
|
-
payload: { groups: ["alpha"] },
|
|
264
|
-
});
|
|
265
|
-
const committed = await waitFor(ownerMessages, (message) => message.type === "extension_state_result");
|
|
266
|
-
assert.equal(committed.type, "extension_state_result");
|
|
267
|
-
assert.equal(committed.committed, true);
|
|
268
|
-
assert.equal(committed.revision, 1);
|
|
269
|
-
const state = await waitFor(peerMessages, (message) => message.type === "extension_state");
|
|
270
|
-
assert.equal(state.type, "extension_state");
|
|
271
|
-
assert.deepEqual(state.payload, { groups: ["alpha"] });
|
|
272
|
-
|
|
273
|
-
owner.sendExtensionMessage({
|
|
274
|
-
type: "extension_publish",
|
|
275
|
-
namespace: "test/v1",
|
|
276
|
-
audience: "capable",
|
|
277
|
-
ownerOnly: true,
|
|
278
|
-
payload: {},
|
|
279
|
-
});
|
|
280
|
-
const missingEpochDeadline = Date.now() + 3000;
|
|
281
|
-
while (Date.now() < missingEpochDeadline && !ownerErrors.some((error) => /ownerEpoch required/.test(error.message))) {
|
|
282
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
283
|
-
}
|
|
284
|
-
assert.equal(ownerErrors.some((error) => /ownerEpoch required/.test(error.message)), true);
|
|
285
|
-
ownerErrors.length = 0;
|
|
286
|
-
|
|
287
|
-
owner.sendExtensionMessage({
|
|
288
|
-
type: "extension_publish",
|
|
289
|
-
namespace: "test/v1",
|
|
290
|
-
audience: "capable",
|
|
291
|
-
ownerOnly: true,
|
|
292
|
-
ownerEpoch: "stale",
|
|
293
|
-
payload: {},
|
|
294
|
-
});
|
|
295
|
-
await waitFor(
|
|
296
|
-
ownerMessages,
|
|
297
|
-
(message) => message.type === "error" && message.error === "Owner validation failed",
|
|
298
|
-
).catch(async () => {
|
|
299
|
-
const deadline = Date.now() + 3000;
|
|
300
|
-
while (Date.now() < deadline && ownerErrors.length === 0) {
|
|
301
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
302
|
-
}
|
|
303
|
-
assert.match(ownerErrors[0]?.message ?? "", /Owner validation failed/);
|
|
304
|
-
return { type: "error", error: ownerErrors[0]!.message } as BrokerMessage;
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
await owner.disconnect();
|
|
308
|
-
const noOwner = await waitFor(
|
|
309
|
-
peerMessages,
|
|
310
|
-
(message) => message.type === "extension_owner" && message.ownerId === undefined,
|
|
311
|
-
);
|
|
312
|
-
assert.equal(noOwner.type, "extension_owner");
|
|
313
|
-
|
|
314
|
-
const replacement = new IntercomClient();
|
|
315
|
-
clients.push(replacement);
|
|
316
|
-
const replacementMessages: BrokerMessage[] = [];
|
|
317
|
-
replacement.onBrokerMessage((message) => replacementMessages.push(message));
|
|
318
|
-
replacement.on("error", () => {});
|
|
319
|
-
await replacement.connect(registration("replacement", now + 1, true), "owner-id");
|
|
320
|
-
const replacementOwner = await waitFor(replacementMessages, (message) => message.type === "extension_owner");
|
|
321
|
-
assert.equal(replacementOwner.type, "extension_owner");
|
|
322
|
-
assert.notEqual(replacementOwner.ownerEpoch, ownerEvent.ownerEpoch);
|
|
323
|
-
const restored = await waitFor(replacementMessages, (message) => message.type === "extension_state");
|
|
324
|
-
assert.equal(restored.type, "extension_state");
|
|
325
|
-
assert.equal(restored.revision, 1);
|
|
326
|
-
} finally {
|
|
327
|
-
await Promise.all(clients.map((client) => client.disconnect().catch(() => undefined)));
|
|
328
|
-
await stopBroker(broker);
|
|
329
|
-
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
330
|
-
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
331
|
-
rmSync(agentDir, { recursive: true, force: true });
|
|
332
|
-
}
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
test("extension state compare-and-swap rejects stale and invalid revisions", () => {
|
|
336
|
-
const runtimeDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-state-cas-"));
|
|
337
|
-
try {
|
|
338
|
-
const manager = new ExtensionStateManager(runtimeDir);
|
|
339
|
-
assert.deepEqual(manager.commitState("test/v1", 0, { version: 1 }), { committed: true, revision: 1 });
|
|
340
|
-
assert.deepEqual(manager.commitState("test/v1", 0, { version: 2 }), {
|
|
341
|
-
committed: false,
|
|
342
|
-
revision: 1,
|
|
343
|
-
reason: "Revision mismatch",
|
|
344
|
-
payload: { version: 1 },
|
|
345
|
-
});
|
|
346
|
-
assert.deepEqual(manager.commitState("test/v1", -1, { version: 2 }), {
|
|
347
|
-
committed: false,
|
|
348
|
-
revision: 1,
|
|
349
|
-
reason: "Invalid expected revision",
|
|
350
|
-
});
|
|
351
|
-
assert.deepEqual(manager.commitState("test/v1", 1, { version: 2 }), { committed: true, revision: 2 });
|
|
352
|
-
assert.deepEqual(manager.commitState("test/v1", 2, "x".repeat(64 * 1024)), {
|
|
353
|
-
committed: false,
|
|
354
|
-
revision: 2,
|
|
355
|
-
reason: "Invalid extension state or payload exceeds 64 KiB limit",
|
|
356
|
-
});
|
|
357
|
-
const circular: { self?: unknown } = {};
|
|
358
|
-
circular.self = circular;
|
|
359
|
-
assert.deepEqual(manager.commitState("test/v1", 2, circular), {
|
|
360
|
-
committed: false,
|
|
361
|
-
revision: 2,
|
|
362
|
-
reason: "Invalid extension state or payload exceeds 64 KiB limit",
|
|
363
|
-
});
|
|
364
|
-
} finally {
|
|
365
|
-
rmSync(runtimeDir, { recursive: true, force: true });
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
test("extension state falls back to a valid backup", () => {
|
|
370
|
-
const runtimeDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-state-"));
|
|
371
|
-
try {
|
|
372
|
-
const manager = new ExtensionStateManager(runtimeDir);
|
|
373
|
-
assert.equal(manager.commitState("test/v1", 0, { version: 1 }).committed, true);
|
|
374
|
-
assert.equal(manager.commitState("test/v1", 1, { version: 2 }).committed, true);
|
|
375
|
-
|
|
376
|
-
const stateDir = path.join(runtimeDir, "extension-state");
|
|
377
|
-
const files = readdirSync(stateDir);
|
|
378
|
-
const primary = files.find((file) => file.endsWith(".json"));
|
|
379
|
-
assert.ok(primary);
|
|
380
|
-
writeFileSync(path.join(stateDir, primary!), "corrupt", "utf8");
|
|
381
|
-
|
|
382
|
-
const recovered = new ExtensionStateManager(runtimeDir).loadState("test/v1");
|
|
383
|
-
assert.deepEqual(recovered, { revision: 1, payload: { version: 1 } });
|
|
384
|
-
} finally {
|
|
385
|
-
rmSync(runtimeDir, { recursive: true, force: true });
|
|
386
|
-
}
|
|
387
|
-
});
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import type { BrokerMessage, SessionRegistration } from "../types.ts";
|
|
9
|
+
import { IntercomClient } from "./client.ts";
|
|
10
|
+
import { ExtensionStateManager } from "./extension-state.ts";
|
|
11
|
+
import { getTsxCliPath } from "./spawn.ts";
|
|
12
|
+
|
|
13
|
+
const repoDir = process.cwd();
|
|
14
|
+
|
|
15
|
+
function registration(name: string, startedAt: number, ownerEligible?: boolean): SessionRegistration {
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
cwd: "/test",
|
|
19
|
+
model: "test-model",
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
startedAt,
|
|
22
|
+
lastActivity: Date.now(),
|
|
23
|
+
...(ownerEligible === undefined
|
|
24
|
+
? {}
|
|
25
|
+
: { extensions: [{ namespace: "test/v1", ownerEligible }] }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function waitFor(
|
|
30
|
+
messages: BrokerMessage[],
|
|
31
|
+
predicate: (message: BrokerMessage) => boolean,
|
|
32
|
+
timeoutMs = 3000,
|
|
33
|
+
): Promise<BrokerMessage> {
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
const message = messages.find(predicate);
|
|
37
|
+
if (message) return message;
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
39
|
+
}
|
|
40
|
+
throw new Error("Timed out waiting for broker message");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function startBroker(agentDir: string): Promise<ChildProcessWithoutNullStreams> {
|
|
44
|
+
const broker = spawn(
|
|
45
|
+
process.execPath,
|
|
46
|
+
[getTsxCliPath(repoDir), path.join(repoDir, "broker", "broker.ts")],
|
|
47
|
+
{
|
|
48
|
+
cwd: repoDir,
|
|
49
|
+
env: { ...process.env, SELESAI_CODING_AGENT_DIR: agentDir },
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
54
|
+
const timeout = setTimeout(() => reject(new Error("Broker startup timed out")), 10_000);
|
|
55
|
+
broker.stdout.on("data", (chunk: Buffer) => {
|
|
56
|
+
if (chunk.toString().includes("Intercom broker started")) {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
resolve();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
broker.once("exit", (code, signal) => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
reject(new Error(`Broker exited before startup (${code ?? signal})`));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
await ready;
|
|
67
|
+
return broker;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function stopBroker(broker: ChildProcessWithoutNullStreams): Promise<void> {
|
|
71
|
+
if (broker.exitCode !== null) return;
|
|
72
|
+
broker.kill("SIGTERM");
|
|
73
|
+
await once(broker, "exit");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("extension bus negotiates, routes, elects an owner, and persists state", { concurrency: false, timeout: 30_000 }, async () => {
|
|
77
|
+
const agentDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-extension-"));
|
|
78
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
79
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
80
|
+
const broker = await startBroker(agentDir);
|
|
81
|
+
const clients: IntercomClient[] = [];
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const invalidNamespace = new IntercomClient();
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
invalidNamespace.connect({
|
|
87
|
+
...registration("invalid", Date.now()),
|
|
88
|
+
extensions: [{ namespace: "Invalid Namespace", ownerEligible: true }],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const tooManyExtensions = new IntercomClient();
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
tooManyExtensions.connect({
|
|
95
|
+
...registration("too-many", Date.now()),
|
|
96
|
+
extensions: Array.from({ length: 33 }, (_, index) => ({
|
|
97
|
+
namespace: `test-${index}/v1`,
|
|
98
|
+
ownerEligible: true,
|
|
99
|
+
})),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const owner = new IntercomClient();
|
|
104
|
+
const peer = new IntercomClient();
|
|
105
|
+
const legacy = new IntercomClient();
|
|
106
|
+
const late = new IntercomClient();
|
|
107
|
+
const peerOnlyA = new IntercomClient();
|
|
108
|
+
const peerOnlyB = new IntercomClient();
|
|
109
|
+
clients.push(owner, peer, legacy, late, peerOnlyA, peerOnlyB);
|
|
110
|
+
|
|
111
|
+
const ownerMessages: BrokerMessage[] = [];
|
|
112
|
+
const peerMessages: BrokerMessage[] = [];
|
|
113
|
+
const legacyMessages: BrokerMessage[] = [];
|
|
114
|
+
const lateMessages: BrokerMessage[] = [];
|
|
115
|
+
const peerOnlyBMessages: BrokerMessage[] = [];
|
|
116
|
+
const ownerErrors: Error[] = [];
|
|
117
|
+
const legacyErrors: Error[] = [];
|
|
118
|
+
owner.onBrokerMessage((message) => ownerMessages.push(message));
|
|
119
|
+
peer.onBrokerMessage((message) => peerMessages.push(message));
|
|
120
|
+
legacy.onBrokerMessage((message) => legacyMessages.push(message));
|
|
121
|
+
late.onBrokerMessage((message) => lateMessages.push(message));
|
|
122
|
+
peerOnlyB.onBrokerMessage((message) => peerOnlyBMessages.push(message));
|
|
123
|
+
owner.on("error", (error) => ownerErrors.push(error));
|
|
124
|
+
peer.on("error", () => {});
|
|
125
|
+
legacy.on("error", (error) => legacyErrors.push(error));
|
|
126
|
+
late.on("error", () => {});
|
|
127
|
+
peerOnlyA.on("error", () => {});
|
|
128
|
+
peerOnlyB.on("error", () => {});
|
|
129
|
+
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
await owner.connect(registration("owner", now - 1000, true), "owner-id");
|
|
132
|
+
|
|
133
|
+
const invalidReplacement = new IntercomClient();
|
|
134
|
+
await assert.rejects(invalidReplacement.connect({
|
|
135
|
+
...registration("invalid-replacement", now),
|
|
136
|
+
extensions: [{ namespace: "Invalid Namespace", ownerEligible: true }],
|
|
137
|
+
}, "owner-id"));
|
|
138
|
+
assert.equal((await owner.listSessions()).some((session) => session.id === "owner-id"), true);
|
|
139
|
+
await invalidReplacement.disconnect().catch(() => undefined);
|
|
140
|
+
|
|
141
|
+
await peer.connect(registration("peer", now, false), "peer-id");
|
|
142
|
+
await legacy.connect(registration("legacy", now), "legacy-id");
|
|
143
|
+
await late.connect(registration("late", 0), "late-id");
|
|
144
|
+
await peerOnlyA.connect({
|
|
145
|
+
...registration("peer-only-a", now + 2),
|
|
146
|
+
extensions: [{ namespace: "peer-only/v1", ownerEligible: false }],
|
|
147
|
+
}, "peer-only-a-id");
|
|
148
|
+
await peerOnlyB.connect({
|
|
149
|
+
...registration("peer-only-b", now + 3),
|
|
150
|
+
extensions: [{ namespace: "peer-only/v1", ownerEligible: false }],
|
|
151
|
+
}, "peer-only-b-id");
|
|
152
|
+
|
|
153
|
+
assert.equal(owner.supportsFeature("extension-bus-v1"), true);
|
|
154
|
+
assert.equal(legacy.supportsFeature("extension-bus-v1"), true, "new broker advertises support even to clients without capabilities");
|
|
155
|
+
|
|
156
|
+
const ownerEvent = await waitFor(ownerMessages, (message) => message.type === "extension_owner");
|
|
157
|
+
assert.equal(ownerEvent.type, "extension_owner");
|
|
158
|
+
assert.equal(ownerEvent.ownerId, "owner-id");
|
|
159
|
+
assert.ok(ownerEvent.ownerEpoch);
|
|
160
|
+
const peerOwnerEvent = await waitFor(peerMessages, (message) => message.type === "extension_owner");
|
|
161
|
+
assert.equal(peerOwnerEvent.type, "extension_owner");
|
|
162
|
+
assert.equal(peerOwnerEvent.ownerId, "owner-id");
|
|
163
|
+
|
|
164
|
+
late.updateExtensionCapabilities([{ namespace: "test/v1", ownerEligible: true }]);
|
|
165
|
+
const lateOwnerEvent = await waitFor(lateMessages, (message) => message.type === "extension_owner");
|
|
166
|
+
assert.equal(lateOwnerEvent.type, "extension_owner");
|
|
167
|
+
assert.equal(lateOwnerEvent.ownerId, "owner-id", "backdated client must not seize namespace ownership");
|
|
168
|
+
late.updateExtensionCapabilities([{ namespace: "test/v1", ownerEligible: false }]);
|
|
169
|
+
|
|
170
|
+
legacy.updateExtensionCapabilities([{ namespace: "other/v1", ownerEligible: false }]);
|
|
171
|
+
legacy.sendExtensionMessage({
|
|
172
|
+
type: "extension_publish",
|
|
173
|
+
namespace: "test/v1",
|
|
174
|
+
audience: "capable",
|
|
175
|
+
payload: { unauthorized: true },
|
|
176
|
+
});
|
|
177
|
+
const unauthorizedDeadline = Date.now() + 3000;
|
|
178
|
+
while (Date.now() < unauthorizedDeadline && legacyErrors.length === 0) {
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
+
}
|
|
181
|
+
assert.match(legacyErrors[0]?.message ?? "", /does not have capability for this namespace/);
|
|
182
|
+
legacy.sendExtensionMessage({
|
|
183
|
+
type: "extension_state_commit",
|
|
184
|
+
namespace: "test/v1",
|
|
185
|
+
ownerEpoch: "unauthorized",
|
|
186
|
+
expectedRevision: 0,
|
|
187
|
+
payload: {},
|
|
188
|
+
});
|
|
189
|
+
const unauthorizedState = await waitFor(
|
|
190
|
+
legacyMessages,
|
|
191
|
+
(message) => message.type === "extension_state_result",
|
|
192
|
+
);
|
|
193
|
+
assert.equal(unauthorizedState.type, "extension_state_result");
|
|
194
|
+
assert.equal(unauthorizedState.committed, false);
|
|
195
|
+
assert.match(unauthorizedState.reason ?? "", /does not have capability for this namespace/);
|
|
196
|
+
|
|
197
|
+
owner.sendExtensionMessage({
|
|
198
|
+
type: "extension_publish",
|
|
199
|
+
namespace: "test/v1",
|
|
200
|
+
audience: "capable",
|
|
201
|
+
payload: "x".repeat(16 * 1024),
|
|
202
|
+
});
|
|
203
|
+
const oversizedDeadline = Date.now() + 3000;
|
|
204
|
+
while (Date.now() < oversizedDeadline && !ownerErrors.some((error) => /16 KiB/.test(error.message))) {
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
206
|
+
}
|
|
207
|
+
assert.equal(ownerErrors.some((error) => /16 KiB/.test(error.message)), true);
|
|
208
|
+
ownerErrors.length = 0;
|
|
209
|
+
|
|
210
|
+
peerOnlyA.sendExtensionMessage({
|
|
211
|
+
type: "extension_publish",
|
|
212
|
+
namespace: "peer-only/v1",
|
|
213
|
+
audience: "capable",
|
|
214
|
+
payload: { kind: "peer-broadcast" },
|
|
215
|
+
});
|
|
216
|
+
const peerBroadcast = await waitFor(peerOnlyBMessages, (message) => message.type === "extension_message");
|
|
217
|
+
assert.equal(peerBroadcast.type, "extension_message");
|
|
218
|
+
assert.equal(peerBroadcast.ownerId, undefined);
|
|
219
|
+
assert.equal(peerBroadcast.ownerEpoch, undefined);
|
|
220
|
+
assert.deepEqual(peerBroadcast.payload, { kind: "peer-broadcast" });
|
|
221
|
+
|
|
222
|
+
peer.sendExtensionMessage({
|
|
223
|
+
type: "extension_publish",
|
|
224
|
+
namespace: "test/v1",
|
|
225
|
+
audience: "owner",
|
|
226
|
+
payload: { kind: "owner-target" },
|
|
227
|
+
});
|
|
228
|
+
const ownerTarget = await waitFor(
|
|
229
|
+
ownerMessages,
|
|
230
|
+
(message) => message.type === "extension_message"
|
|
231
|
+
&& (message.payload as { kind?: string }).kind === "owner-target",
|
|
232
|
+
);
|
|
233
|
+
assert.equal(ownerTarget.type, "extension_message");
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
235
|
+
assert.equal(
|
|
236
|
+
peerMessages.some((message) => message.type === "extension_message"
|
|
237
|
+
&& (message.payload as { kind?: string }).kind === "owner-target"),
|
|
238
|
+
false,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
owner.sendExtensionMessage({
|
|
242
|
+
type: "extension_publish",
|
|
243
|
+
namespace: "test/v1",
|
|
244
|
+
audience: "capable",
|
|
245
|
+
ownerOnly: true,
|
|
246
|
+
ownerEpoch: ownerEvent.ownerEpoch,
|
|
247
|
+
payload: { kind: "assignment" },
|
|
248
|
+
});
|
|
249
|
+
const received = await waitFor(peerMessages, (message) => message.type === "extension_message");
|
|
250
|
+
assert.equal(received.type, "extension_message");
|
|
251
|
+
assert.deepEqual(received.payload, { kind: "assignment" });
|
|
252
|
+
const lateReceived = await waitFor(lateMessages, (message) => message.type === "extension_message");
|
|
253
|
+
assert.equal(lateReceived.type, "extension_message");
|
|
254
|
+
assert.deepEqual(lateReceived.payload, { kind: "assignment" });
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
256
|
+
assert.equal(legacyMessages.some((message) => message.type === "extension_message"), false);
|
|
257
|
+
|
|
258
|
+
owner.sendExtensionMessage({
|
|
259
|
+
type: "extension_state_commit",
|
|
260
|
+
namespace: "test/v1",
|
|
261
|
+
ownerEpoch: ownerEvent.ownerEpoch!,
|
|
262
|
+
expectedRevision: 0,
|
|
263
|
+
payload: { groups: ["alpha"] },
|
|
264
|
+
});
|
|
265
|
+
const committed = await waitFor(ownerMessages, (message) => message.type === "extension_state_result");
|
|
266
|
+
assert.equal(committed.type, "extension_state_result");
|
|
267
|
+
assert.equal(committed.committed, true);
|
|
268
|
+
assert.equal(committed.revision, 1);
|
|
269
|
+
const state = await waitFor(peerMessages, (message) => message.type === "extension_state");
|
|
270
|
+
assert.equal(state.type, "extension_state");
|
|
271
|
+
assert.deepEqual(state.payload, { groups: ["alpha"] });
|
|
272
|
+
|
|
273
|
+
owner.sendExtensionMessage({
|
|
274
|
+
type: "extension_publish",
|
|
275
|
+
namespace: "test/v1",
|
|
276
|
+
audience: "capable",
|
|
277
|
+
ownerOnly: true,
|
|
278
|
+
payload: {},
|
|
279
|
+
});
|
|
280
|
+
const missingEpochDeadline = Date.now() + 3000;
|
|
281
|
+
while (Date.now() < missingEpochDeadline && !ownerErrors.some((error) => /ownerEpoch required/.test(error.message))) {
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
283
|
+
}
|
|
284
|
+
assert.equal(ownerErrors.some((error) => /ownerEpoch required/.test(error.message)), true);
|
|
285
|
+
ownerErrors.length = 0;
|
|
286
|
+
|
|
287
|
+
owner.sendExtensionMessage({
|
|
288
|
+
type: "extension_publish",
|
|
289
|
+
namespace: "test/v1",
|
|
290
|
+
audience: "capable",
|
|
291
|
+
ownerOnly: true,
|
|
292
|
+
ownerEpoch: "stale",
|
|
293
|
+
payload: {},
|
|
294
|
+
});
|
|
295
|
+
await waitFor(
|
|
296
|
+
ownerMessages,
|
|
297
|
+
(message) => message.type === "error" && message.error === "Owner validation failed",
|
|
298
|
+
).catch(async () => {
|
|
299
|
+
const deadline = Date.now() + 3000;
|
|
300
|
+
while (Date.now() < deadline && ownerErrors.length === 0) {
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
302
|
+
}
|
|
303
|
+
assert.match(ownerErrors[0]?.message ?? "", /Owner validation failed/);
|
|
304
|
+
return { type: "error", error: ownerErrors[0]!.message } as BrokerMessage;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await owner.disconnect();
|
|
308
|
+
const noOwner = await waitFor(
|
|
309
|
+
peerMessages,
|
|
310
|
+
(message) => message.type === "extension_owner" && message.ownerId === undefined,
|
|
311
|
+
);
|
|
312
|
+
assert.equal(noOwner.type, "extension_owner");
|
|
313
|
+
|
|
314
|
+
const replacement = new IntercomClient();
|
|
315
|
+
clients.push(replacement);
|
|
316
|
+
const replacementMessages: BrokerMessage[] = [];
|
|
317
|
+
replacement.onBrokerMessage((message) => replacementMessages.push(message));
|
|
318
|
+
replacement.on("error", () => {});
|
|
319
|
+
await replacement.connect(registration("replacement", now + 1, true), "owner-id");
|
|
320
|
+
const replacementOwner = await waitFor(replacementMessages, (message) => message.type === "extension_owner");
|
|
321
|
+
assert.equal(replacementOwner.type, "extension_owner");
|
|
322
|
+
assert.notEqual(replacementOwner.ownerEpoch, ownerEvent.ownerEpoch);
|
|
323
|
+
const restored = await waitFor(replacementMessages, (message) => message.type === "extension_state");
|
|
324
|
+
assert.equal(restored.type, "extension_state");
|
|
325
|
+
assert.equal(restored.revision, 1);
|
|
326
|
+
} finally {
|
|
327
|
+
await Promise.all(clients.map((client) => client.disconnect().catch(() => undefined)));
|
|
328
|
+
await stopBroker(broker);
|
|
329
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
330
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
331
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("extension state compare-and-swap rejects stale and invalid revisions", () => {
|
|
336
|
+
const runtimeDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-state-cas-"));
|
|
337
|
+
try {
|
|
338
|
+
const manager = new ExtensionStateManager(runtimeDir);
|
|
339
|
+
assert.deepEqual(manager.commitState("test/v1", 0, { version: 1 }), { committed: true, revision: 1 });
|
|
340
|
+
assert.deepEqual(manager.commitState("test/v1", 0, { version: 2 }), {
|
|
341
|
+
committed: false,
|
|
342
|
+
revision: 1,
|
|
343
|
+
reason: "Revision mismatch",
|
|
344
|
+
payload: { version: 1 },
|
|
345
|
+
});
|
|
346
|
+
assert.deepEqual(manager.commitState("test/v1", -1, { version: 2 }), {
|
|
347
|
+
committed: false,
|
|
348
|
+
revision: 1,
|
|
349
|
+
reason: "Invalid expected revision",
|
|
350
|
+
});
|
|
351
|
+
assert.deepEqual(manager.commitState("test/v1", 1, { version: 2 }), { committed: true, revision: 2 });
|
|
352
|
+
assert.deepEqual(manager.commitState("test/v1", 2, "x".repeat(64 * 1024)), {
|
|
353
|
+
committed: false,
|
|
354
|
+
revision: 2,
|
|
355
|
+
reason: "Invalid extension state or payload exceeds 64 KiB limit",
|
|
356
|
+
});
|
|
357
|
+
const circular: { self?: unknown } = {};
|
|
358
|
+
circular.self = circular;
|
|
359
|
+
assert.deepEqual(manager.commitState("test/v1", 2, circular), {
|
|
360
|
+
committed: false,
|
|
361
|
+
revision: 2,
|
|
362
|
+
reason: "Invalid extension state or payload exceeds 64 KiB limit",
|
|
363
|
+
});
|
|
364
|
+
} finally {
|
|
365
|
+
rmSync(runtimeDir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("extension state falls back to a valid backup", () => {
|
|
370
|
+
const runtimeDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-state-"));
|
|
371
|
+
try {
|
|
372
|
+
const manager = new ExtensionStateManager(runtimeDir);
|
|
373
|
+
assert.equal(manager.commitState("test/v1", 0, { version: 1 }).committed, true);
|
|
374
|
+
assert.equal(manager.commitState("test/v1", 1, { version: 2 }).committed, true);
|
|
375
|
+
|
|
376
|
+
const stateDir = path.join(runtimeDir, "extension-state");
|
|
377
|
+
const files = readdirSync(stateDir);
|
|
378
|
+
const primary = files.find((file) => file.endsWith(".json"));
|
|
379
|
+
assert.ok(primary);
|
|
380
|
+
writeFileSync(path.join(stateDir, primary!), "corrupt", "utf8");
|
|
381
|
+
|
|
382
|
+
const recovered = new ExtensionStateManager(runtimeDir).loadState("test/v1");
|
|
383
|
+
assert.deepEqual(recovered, { revision: 1, payload: { version: 1 } });
|
|
384
|
+
} finally {
|
|
385
|
+
rmSync(runtimeDir, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
});
|