@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
// Tests for the Linear Agent Session Bridge.
|
|
2
|
+
// Covers session creation from AgentSessionEvent, follow-up prompt handling,
|
|
3
|
+
// message relay from Companion sessions to Linear activities, cleanup,
|
|
4
|
+
// session persistence, plan relay, enriched prompts, tool results, and progress flush.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { companionBus } from "./event-bus.js";
|
|
8
|
+
|
|
9
|
+
// Mock dependencies
|
|
10
|
+
vi.mock("./agent-store.js", () => ({
|
|
11
|
+
listAgents: vi.fn(),
|
|
12
|
+
getAgent: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("./linear-agent.js", () => ({
|
|
16
|
+
postActivity: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
updateSessionUrls: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
updateSessionPlan: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./settings-manager.js", () => ({
|
|
22
|
+
getSettings: vi.fn().mockReturnValue({ publicUrl: "" }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Mock the OAuth connections module used by the bridge for credential resolution
|
|
26
|
+
// and agent lookup. Default: no connections found (falls through to legacy path).
|
|
27
|
+
vi.mock("./linear-oauth-connections.js", () => ({
|
|
28
|
+
findOAuthConnectionByClientId: vi.fn().mockReturnValue(null),
|
|
29
|
+
getOAuthConnection: vi.fn().mockReturnValue(null),
|
|
30
|
+
updateOAuthConnection: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import * as agentStore from "./agent-store.js";
|
|
34
|
+
import * as linearAgent from "./linear-agent.js";
|
|
35
|
+
import * as linearOAuthConnections from "./linear-oauth-connections.js";
|
|
36
|
+
import { LinearAgentBridge, buildPrompt } from "./linear-agent-bridge.js";
|
|
37
|
+
import type { AgentSessionEventPayload } from "./linear-agent.js";
|
|
38
|
+
|
|
39
|
+
// ─── Test helpers ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function createMockAgentExecutor() {
|
|
42
|
+
return {
|
|
43
|
+
executeAgent: vi.fn(),
|
|
44
|
+
} as unknown as import("./agent-executor.js").AgentExecutor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createMockWsBridge(linearMappings: Array<{ sessionId: string; linearSessionId: string }> = []) {
|
|
48
|
+
return {
|
|
49
|
+
injectUserMessage: vi.fn(),
|
|
50
|
+
getSession: vi.fn().mockReturnValue({ id: "mock-session" }), // session exists by default
|
|
51
|
+
setLinearSessionId: vi.fn(),
|
|
52
|
+
getLinearSessionMappings: vi.fn().mockReturnValue(linearMappings),
|
|
53
|
+
} as unknown as import("./ws-bridge.js").WsBridge;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeCreatedEvent(overrides: Partial<AgentSessionEventPayload> = {}): AgentSessionEventPayload {
|
|
57
|
+
return {
|
|
58
|
+
action: "created",
|
|
59
|
+
type: "AgentSessionEvent",
|
|
60
|
+
oauthClientId: "test-oauth-client-id",
|
|
61
|
+
agentSession: {
|
|
62
|
+
id: "linear-session-1",
|
|
63
|
+
status: "pending",
|
|
64
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
65
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
66
|
+
},
|
|
67
|
+
promptContext: "Fix the login bug on issue LIN-42",
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makePromptedEvent(linearSessionId: string, message: string): AgentSessionEventPayload {
|
|
73
|
+
return {
|
|
74
|
+
action: "prompted",
|
|
75
|
+
type: "AgentSessionEvent",
|
|
76
|
+
oauthClientId: "test-oauth-client-id",
|
|
77
|
+
agentSession: {
|
|
78
|
+
id: linearSessionId,
|
|
79
|
+
status: "inProgress",
|
|
80
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
81
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
82
|
+
},
|
|
83
|
+
agentActivity: { body: message },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const testAgent = {
|
|
88
|
+
id: "agent-1",
|
|
89
|
+
name: "Linear Bot",
|
|
90
|
+
enabled: true,
|
|
91
|
+
triggers: { linear: { enabled: true, oauthClientId: "test-oauth-client-id" } },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe("LinearAgentBridge", () => {
|
|
97
|
+
let bridge: LinearAgentBridge;
|
|
98
|
+
let executor: ReturnType<typeof createMockAgentExecutor>;
|
|
99
|
+
let wsBridge: ReturnType<typeof createMockWsBridge>;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
companionBus.clear();
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
// Default: getAgent returns the testAgent (needed for setupRelay credential lookup)
|
|
106
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(testAgent as ReturnType<typeof agentStore.getAgent>);
|
|
107
|
+
executor = createMockAgentExecutor();
|
|
108
|
+
wsBridge = createMockWsBridge();
|
|
109
|
+
bridge = new LinearAgentBridge(executor, wsBridge);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
bridge.shutdown();
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("handleEvent — created action", () => {
|
|
118
|
+
it("acknowledges with a thought, launches agent session, and sets up relay", async () => {
|
|
119
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
120
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
121
|
+
|
|
122
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
123
|
+
|
|
124
|
+
// Should post initial acknowledgement thought
|
|
125
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
126
|
+
expect.any(Object),
|
|
127
|
+
"linear-session-1",
|
|
128
|
+
expect.objectContaining({ type: "thought", body: "Starting Companion session..." }),
|
|
129
|
+
expect.any(Function),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Should launch agent session with prompt context
|
|
133
|
+
expect(executor.executeAgent).toHaveBeenCalledWith(
|
|
134
|
+
"agent-1",
|
|
135
|
+
"Fix the login bug on issue LIN-42",
|
|
136
|
+
{ force: true, triggerType: "linear" },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Should set external URLs
|
|
140
|
+
expect(linearAgent.updateSessionUrls).toHaveBeenCalledWith(
|
|
141
|
+
expect.any(Object),
|
|
142
|
+
"linear-session-1",
|
|
143
|
+
expect.arrayContaining([
|
|
144
|
+
expect.objectContaining({ label: "Companion Session" }),
|
|
145
|
+
]),
|
|
146
|
+
expect.any(Function),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Should set up relay listeners on the event bus
|
|
150
|
+
expect(companionBus.listenerCount("message:assistant")).toBeGreaterThan(0);
|
|
151
|
+
expect(companionBus.listenerCount("message:result")).toBeGreaterThan(0);
|
|
152
|
+
|
|
153
|
+
// Should post "session started" thought
|
|
154
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
155
|
+
expect.any(Object),
|
|
156
|
+
"linear-session-1",
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
type: "thought",
|
|
159
|
+
body: expect.stringContaining("Linear Bot"),
|
|
160
|
+
}),
|
|
161
|
+
expect.any(Function),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("injects OAuth-backed Linear API access into the spawned agent session", async () => {
|
|
166
|
+
const oauthAgent = {
|
|
167
|
+
...testAgent,
|
|
168
|
+
triggers: { linear: { enabled: true, oauthConnectionId: "oauth-1" } },
|
|
169
|
+
};
|
|
170
|
+
const oauthConn = {
|
|
171
|
+
id: "oauth-1",
|
|
172
|
+
name: "Enrich",
|
|
173
|
+
oauthClientId: "test-oauth-client-id",
|
|
174
|
+
oauthClientSecret: "secret",
|
|
175
|
+
webhookSecret: "hook",
|
|
176
|
+
accessToken: "lin_oauth_test",
|
|
177
|
+
refreshToken: "lin_refresh_test",
|
|
178
|
+
status: "connected" as const,
|
|
179
|
+
createdAt: 1,
|
|
180
|
+
updatedAt: 1,
|
|
181
|
+
};
|
|
182
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([oauthAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
183
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(oauthAgent as ReturnType<typeof agentStore.getAgent>);
|
|
184
|
+
vi.mocked(linearOAuthConnections.findOAuthConnectionByClientId).mockReturnValue(oauthConn);
|
|
185
|
+
vi.mocked(linearOAuthConnections.getOAuthConnection).mockReturnValue(oauthConn);
|
|
186
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
187
|
+
|
|
188
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
189
|
+
|
|
190
|
+
expect(executor.executeAgent).toHaveBeenCalledWith(
|
|
191
|
+
"agent-1",
|
|
192
|
+
"Fix the login bug on issue LIN-42",
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
force: true,
|
|
195
|
+
triggerType: "linear",
|
|
196
|
+
additionalEnv: {
|
|
197
|
+
LINEAR_OAUTH_ACCESS_TOKEN: "lin_oauth_test",
|
|
198
|
+
LINEAR_API_KEY: "lin_oauth_test",
|
|
199
|
+
},
|
|
200
|
+
systemPrompt: expect.stringContaining("LINEAR_OAUTH_ACCESS_TOKEN"),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("persists the linear session ID on the Companion session", async () => {
|
|
206
|
+
// Verifies that setLinearSessionId is called so the mapping survives server restarts.
|
|
207
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
208
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
209
|
+
|
|
210
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
211
|
+
|
|
212
|
+
expect(wsBridge.setLinearSessionId).toHaveBeenCalledWith("comp-sess-1", "linear-session-1");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("logs error and returns when no agent matches the oauthClientId", async () => {
|
|
216
|
+
// No agents configured — findLinearAgentByClientId returns null
|
|
217
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([]);
|
|
218
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
219
|
+
|
|
220
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
221
|
+
|
|
222
|
+
// Can't post activity without credentials — just logs
|
|
223
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
224
|
+
expect.stringContaining("No agent configured for oauthClientId"),
|
|
225
|
+
);
|
|
226
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalled();
|
|
227
|
+
// Should not attempt to launch session
|
|
228
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
229
|
+
consoleSpy.mockRestore();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("posts error when agent executor returns null (no overlap)", async () => {
|
|
233
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
234
|
+
vi.mocked(agentStore.getAgent).mockReturnValue({ ...testAgent, lastSessionId: undefined } as ReturnType<typeof agentStore.getAgent>);
|
|
235
|
+
vi.mocked(executor.executeAgent).mockResolvedValue(undefined as never);
|
|
236
|
+
|
|
237
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
238
|
+
|
|
239
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
240
|
+
expect.any(Object),
|
|
241
|
+
"linear-session-1",
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
type: "error",
|
|
244
|
+
body: expect.stringContaining("Failed to start Companion session"),
|
|
245
|
+
}),
|
|
246
|
+
expect.any(Function),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("posts 'agent busy' error when executor returns null due to overlap", async () => {
|
|
251
|
+
// Agent is busy with a running session
|
|
252
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
253
|
+
vi.mocked(agentStore.getAgent).mockReturnValue({ ...testAgent, lastSessionId: "running-session" } as ReturnType<typeof agentStore.getAgent>);
|
|
254
|
+
vi.mocked(wsBridge.getSession).mockReturnValue({ id: "running-session" } as never);
|
|
255
|
+
vi.mocked(executor.executeAgent).mockResolvedValue(undefined as never);
|
|
256
|
+
|
|
257
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
258
|
+
|
|
259
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
260
|
+
expect.any(Object),
|
|
261
|
+
"linear-session-1",
|
|
262
|
+
expect.objectContaining({
|
|
263
|
+
type: "error",
|
|
264
|
+
body: expect.stringContaining("currently busy"),
|
|
265
|
+
}),
|
|
266
|
+
expect.any(Function),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("posts error when agent executor throws", async () => {
|
|
271
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
272
|
+
vi.mocked(executor.executeAgent).mockRejectedValue(new Error("CLI not found"));
|
|
273
|
+
|
|
274
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
275
|
+
|
|
276
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
277
|
+
expect.any(Object),
|
|
278
|
+
"linear-session-1",
|
|
279
|
+
expect.objectContaining({
|
|
280
|
+
type: "error",
|
|
281
|
+
body: expect.stringContaining("CLI not found"),
|
|
282
|
+
}),
|
|
283
|
+
expect.any(Function),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("enriches prompt with issue context when present", async () => {
|
|
288
|
+
// When a payload has structured issue data, the prompt should include
|
|
289
|
+
// the issue identifier, title, URL, and description before the XML.
|
|
290
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
291
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-2" } as never);
|
|
292
|
+
|
|
293
|
+
await bridge.handleEvent({
|
|
294
|
+
action: "created",
|
|
295
|
+
type: "AgentSessionEvent",
|
|
296
|
+
oauthClientId: "test-oauth-client-id",
|
|
297
|
+
agentSession: {
|
|
298
|
+
id: "real-linear-session",
|
|
299
|
+
status: "pending",
|
|
300
|
+
createdAt: "2026-03-13T16:59:47.380Z",
|
|
301
|
+
updatedAt: "2026-03-13T16:59:47.380Z",
|
|
302
|
+
issue: {
|
|
303
|
+
id: "issue-1",
|
|
304
|
+
title: "Fix bug",
|
|
305
|
+
identifier: "THE-42",
|
|
306
|
+
url: "https://linear.app/the/issue/THE-42",
|
|
307
|
+
description: "Login fails when email has a plus sign",
|
|
308
|
+
},
|
|
309
|
+
comment: {
|
|
310
|
+
id: "comment-1",
|
|
311
|
+
body: "Please fix this ASAP",
|
|
312
|
+
userId: "user-1",
|
|
313
|
+
issueId: "issue-1",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
promptContext: "<issue identifier=\"THE-42\"><title>Fix bug</title></issue>",
|
|
317
|
+
organizationId: "org-1",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// The enriched prompt should contain issue details followed by the XML
|
|
321
|
+
const prompt = vi.mocked(executor.executeAgent).mock.calls[0][1] as string;
|
|
322
|
+
expect(prompt).toContain("[Linear Issue THE-42] Fix bug");
|
|
323
|
+
expect(prompt).toContain("URL: https://linear.app/the/issue/THE-42");
|
|
324
|
+
expect(prompt).toContain("Login fails when email has a plus sign");
|
|
325
|
+
expect(prompt).toContain("Please fix this ASAP");
|
|
326
|
+
expect(prompt).toContain("<issue identifier=\"THE-42\">");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("falls back to raw promptContext when no structured data is present", async () => {
|
|
330
|
+
// When payload has no issue/comment/guidance, just use promptContext as-is.
|
|
331
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
332
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-2" } as never);
|
|
333
|
+
|
|
334
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
335
|
+
|
|
336
|
+
expect(executor.executeAgent).toHaveBeenCalledWith(
|
|
337
|
+
"agent-1",
|
|
338
|
+
"Fix the login bug on issue LIN-42",
|
|
339
|
+
{ force: true, triggerType: "linear" },
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("returns early when agentSession is missing from payload", async () => {
|
|
344
|
+
// Malformed payload without agentSession
|
|
345
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
346
|
+
|
|
347
|
+
await bridge.handleEvent({
|
|
348
|
+
action: "created",
|
|
349
|
+
type: "AgentSessionEvent",
|
|
350
|
+
} as AgentSessionEventPayload);
|
|
351
|
+
|
|
352
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
353
|
+
expect.stringContaining("No session ID found"),
|
|
354
|
+
expect.any(String),
|
|
355
|
+
);
|
|
356
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
357
|
+
consoleSpy.mockRestore();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("skips disabled agents when finding Linear agent by clientId", async () => {
|
|
361
|
+
// Disabled agent has matching clientId but is disabled — should not be found
|
|
362
|
+
const disabledAgent = { ...testAgent, enabled: false };
|
|
363
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([disabledAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
364
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
365
|
+
|
|
366
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
367
|
+
|
|
368
|
+
// Can't post activity without credentials — just logs
|
|
369
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
370
|
+
expect.stringContaining("No agent configured for oauthClientId"),
|
|
371
|
+
);
|
|
372
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalled();
|
|
373
|
+
consoleSpy.mockRestore();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("handleEvent — prompted action", () => {
|
|
378
|
+
it("injects follow-up message into existing Companion session", async () => {
|
|
379
|
+
// First, create a session to establish the mapping
|
|
380
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
381
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
382
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
383
|
+
|
|
384
|
+
vi.clearAllMocks();
|
|
385
|
+
// Re-mock getAgent after clearAllMocks (needed for credential lookup in handlePrompted/setupRelay)
|
|
386
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(testAgent as ReturnType<typeof agentStore.getAgent>);
|
|
387
|
+
|
|
388
|
+
// Now send a follow-up
|
|
389
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", "What's the status?"));
|
|
390
|
+
|
|
391
|
+
// Should post acknowledgement thought
|
|
392
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
393
|
+
expect.any(Object),
|
|
394
|
+
"linear-session-1",
|
|
395
|
+
expect.objectContaining({ type: "thought", body: "Processing follow-up..." }),
|
|
396
|
+
expect.any(Function),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// Should inject message into the Companion session
|
|
400
|
+
expect(wsBridge.injectUserMessage).toHaveBeenCalledWith("comp-sess-1", "What's the status?");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("creates new session with follow-up message when Companion session is dead", async () => {
|
|
404
|
+
// Create a session first
|
|
405
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
406
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
407
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
408
|
+
vi.clearAllMocks();
|
|
409
|
+
|
|
410
|
+
// Simulate the session being dead
|
|
411
|
+
vi.mocked(wsBridge.getSession).mockReturnValue(undefined);
|
|
412
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
413
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-new" } as never);
|
|
414
|
+
|
|
415
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", "Follow up?"));
|
|
416
|
+
|
|
417
|
+
// Should launch a new session with the follow-up message as prompt context
|
|
418
|
+
expect(executor.executeAgent).toHaveBeenCalledWith(
|
|
419
|
+
"agent-1",
|
|
420
|
+
"Follow up?",
|
|
421
|
+
expect.objectContaining({ triggerType: "linear" }),
|
|
422
|
+
);
|
|
423
|
+
expect(wsBridge.injectUserMessage).not.toHaveBeenCalled();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("creates new session with follow-up message when no mapping exists", async () => {
|
|
427
|
+
// Send prompted event without a prior created event — the user's
|
|
428
|
+
// message (agentActivity.body) should be passed as promptContext
|
|
429
|
+
// to the new session so the message is not silently dropped.
|
|
430
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
431
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-new" } as never);
|
|
432
|
+
|
|
433
|
+
await bridge.handleEvent(makePromptedEvent("unknown-session", "help"));
|
|
434
|
+
|
|
435
|
+
// Should fall back to handleCreated with the follow-up message as prompt
|
|
436
|
+
expect(executor.executeAgent).toHaveBeenCalledWith(
|
|
437
|
+
"agent-1",
|
|
438
|
+
"help",
|
|
439
|
+
expect.objectContaining({ triggerType: "linear" }),
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("ignores prompted events with empty or whitespace-only messages", async () => {
|
|
444
|
+
// Empty agentActivity.body should be silently skipped — no injection, no new session
|
|
445
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
446
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
447
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
448
|
+
vi.clearAllMocks();
|
|
449
|
+
|
|
450
|
+
// Send a follow-up with empty body
|
|
451
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", ""));
|
|
452
|
+
expect(wsBridge.injectUserMessage).not.toHaveBeenCalled();
|
|
453
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
454
|
+
|
|
455
|
+
// Send a follow-up with whitespace-only body
|
|
456
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", " "));
|
|
457
|
+
expect(wsBridge.injectUserMessage).not.toHaveBeenCalled();
|
|
458
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe("session persistence", () => {
|
|
463
|
+
// Verifies that Linear↔Companion session mappings are restored from
|
|
464
|
+
// persisted SessionState on construction.
|
|
465
|
+
|
|
466
|
+
it("restores session mappings from wsBridge on construction", async () => {
|
|
467
|
+
// Create a bridge with pre-existing mappings (simulates server restart)
|
|
468
|
+
// listAgents must be mocked before construction for findAnyLinearAgentId
|
|
469
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
470
|
+
const wsBridgeWithMappings = createMockWsBridge([
|
|
471
|
+
{ sessionId: "comp-restored-1", linearSessionId: "linear-restored-1" },
|
|
472
|
+
]);
|
|
473
|
+
const restoredBridge = new LinearAgentBridge(executor, wsBridgeWithMappings);
|
|
474
|
+
|
|
475
|
+
// Now a prompted event for the restored session should use the existing mapping
|
|
476
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
477
|
+
vi.mocked(wsBridgeWithMappings.getSession).mockReturnValue({ id: "comp-restored-1" } as never);
|
|
478
|
+
|
|
479
|
+
await restoredBridge.handleEvent(makePromptedEvent("linear-restored-1", "Still there?"));
|
|
480
|
+
|
|
481
|
+
// Should inject into the restored session, NOT create a new one
|
|
482
|
+
expect(wsBridgeWithMappings.injectUserMessage).toHaveBeenCalledWith("comp-restored-1", "Still there?");
|
|
483
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
484
|
+
|
|
485
|
+
restoredBridge.shutdown();
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("relay — assistant message callbacks", () => {
|
|
490
|
+
// These tests exercise the relay subscriptions that are registered
|
|
491
|
+
// inside setupRelay via companionBus. We emit events on the bus directly
|
|
492
|
+
// with synthetic BrowserIncomingMessage payloads.
|
|
493
|
+
|
|
494
|
+
async function createSessionAndSetupRelay() {
|
|
495
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
496
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
497
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
498
|
+
|
|
499
|
+
vi.clearAllMocks(); // clear previous postActivity calls
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Emit an assistant message for the test session via the bus. */
|
|
503
|
+
function emitAssistant(msg: unknown) {
|
|
504
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: msg } as any);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function emitStreamText(text: string) {
|
|
508
|
+
companionBus.emit("message:stream_event", {
|
|
509
|
+
sessionId: "comp-sess-1",
|
|
510
|
+
message: {
|
|
511
|
+
type: "stream_event",
|
|
512
|
+
event: {
|
|
513
|
+
type: "content_block_delta",
|
|
514
|
+
delta: { type: "text_delta", text },
|
|
515
|
+
},
|
|
516
|
+
parent_tool_use_id: null,
|
|
517
|
+
},
|
|
518
|
+
} as any);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Emit a result message for the test session via the bus. */
|
|
522
|
+
async function emitResult(msg: unknown = {}) {
|
|
523
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: msg } as any);
|
|
524
|
+
// Allow async result handler to settle
|
|
525
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
it("relays assistant text content as a response on turn completion", async () => {
|
|
529
|
+
await createSessionAndSetupRelay();
|
|
530
|
+
|
|
531
|
+
// Simulate an assistant message with text content
|
|
532
|
+
emitAssistant({
|
|
533
|
+
type: "assistant",
|
|
534
|
+
message: {
|
|
535
|
+
content: [
|
|
536
|
+
{ type: "text", text: "Here is the fix for the login bug." },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Trigger turn completion — should post the accumulated text as a response
|
|
542
|
+
await emitResult();
|
|
543
|
+
|
|
544
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
545
|
+
expect.any(Object),
|
|
546
|
+
"linear-session-1",
|
|
547
|
+
expect.objectContaining({ type: "response", body: "Here is the fix for the login bug." }),
|
|
548
|
+
expect.any(Function),
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("relays streamed text as a response on turn completion", async () => {
|
|
553
|
+
await createSessionAndSetupRelay();
|
|
554
|
+
|
|
555
|
+
emitStreamText("Here is");
|
|
556
|
+
emitStreamText(" the streamed reply.");
|
|
557
|
+
|
|
558
|
+
await emitResult();
|
|
559
|
+
|
|
560
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
561
|
+
expect.any(Object),
|
|
562
|
+
"linear-session-1",
|
|
563
|
+
expect.objectContaining({ type: "response", body: "Here is the streamed reply." }),
|
|
564
|
+
expect.any(Function),
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("does not duplicate final assistant text already seen in stream deltas", async () => {
|
|
569
|
+
await createSessionAndSetupRelay();
|
|
570
|
+
|
|
571
|
+
emitStreamText("Streamed text");
|
|
572
|
+
emitAssistant({
|
|
573
|
+
type: "assistant",
|
|
574
|
+
message: {
|
|
575
|
+
content: [{ type: "text", text: "Streamed text" }],
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await emitResult();
|
|
580
|
+
|
|
581
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
582
|
+
expect.any(Object),
|
|
583
|
+
"linear-session-1",
|
|
584
|
+
expect.objectContaining({ type: "response", body: "Streamed text" }),
|
|
585
|
+
expect.any(Function),
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("relays tool use as action activities", async () => {
|
|
590
|
+
await createSessionAndSetupRelay();
|
|
591
|
+
|
|
592
|
+
// Simulate an assistant message with a tool_use content block
|
|
593
|
+
emitAssistant({
|
|
594
|
+
type: "assistant",
|
|
595
|
+
message: {
|
|
596
|
+
content: [
|
|
597
|
+
{ type: "tool_use", name: "Edit", input: { file: "login.ts", line: 42 } },
|
|
598
|
+
],
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
603
|
+
expect.any(Object),
|
|
604
|
+
"linear-session-1",
|
|
605
|
+
expect.objectContaining({
|
|
606
|
+
type: "action",
|
|
607
|
+
action: "Edit",
|
|
608
|
+
}),
|
|
609
|
+
expect.any(Function),
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("relays all tool_use blocks when assistant calls multiple tools", async () => {
|
|
614
|
+
await createSessionAndSetupRelay();
|
|
615
|
+
|
|
616
|
+
// Simulate an assistant message with multiple parallel tool calls
|
|
617
|
+
emitAssistant({
|
|
618
|
+
type: "assistant",
|
|
619
|
+
message: {
|
|
620
|
+
content: [
|
|
621
|
+
{ type: "tool_use", name: "Read", input: { file: "a.ts" } },
|
|
622
|
+
{ type: "tool_use", name: "Read", input: { file: "b.ts" } },
|
|
623
|
+
{ type: "tool_use", name: "Edit", input: { file: "c.ts" } },
|
|
624
|
+
],
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// All three tool_use blocks should be posted as action activities
|
|
629
|
+
expect(linearAgent.postActivity).toHaveBeenCalledTimes(3);
|
|
630
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
631
|
+
expect.any(Object),
|
|
632
|
+
"linear-session-1",
|
|
633
|
+
expect.objectContaining({ type: "action", action: "Read" }),
|
|
634
|
+
expect.any(Function),
|
|
635
|
+
);
|
|
636
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
637
|
+
expect.any(Object),
|
|
638
|
+
"linear-session-1",
|
|
639
|
+
expect.objectContaining({ type: "action", action: "Edit" }),
|
|
640
|
+
expect.any(Function),
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("accumulates text across multiple assistant messages", async () => {
|
|
645
|
+
await createSessionAndSetupRelay();
|
|
646
|
+
|
|
647
|
+
// Two assistant messages before turn completion
|
|
648
|
+
emitAssistant({
|
|
649
|
+
type: "assistant",
|
|
650
|
+
message: { content: [{ type: "text", text: "Line 1" }] },
|
|
651
|
+
});
|
|
652
|
+
emitAssistant({
|
|
653
|
+
type: "assistant",
|
|
654
|
+
message: { content: [{ type: "text", text: "Line 2" }] },
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await emitResult();
|
|
658
|
+
|
|
659
|
+
// Should accumulate both into one response
|
|
660
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
661
|
+
expect.any(Object),
|
|
662
|
+
"linear-session-1",
|
|
663
|
+
expect.objectContaining({ type: "response", body: "Line 1\nLine 2" }),
|
|
664
|
+
expect.any(Function),
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("does not post empty response when no text was accumulated", async () => {
|
|
669
|
+
await createSessionAndSetupRelay();
|
|
670
|
+
|
|
671
|
+
// Turn completes with no assistant messages
|
|
672
|
+
await emitResult();
|
|
673
|
+
|
|
674
|
+
// Should not post a response activity
|
|
675
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalledWith(
|
|
676
|
+
"linear-session-1",
|
|
677
|
+
expect.objectContaining({ type: "response" }),
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("ignores non-assistant messages in text extraction", async () => {
|
|
682
|
+
await createSessionAndSetupRelay();
|
|
683
|
+
|
|
684
|
+
// A non-assistant message type should be ignored
|
|
685
|
+
emitAssistant({ type: "system", message: "hello" });
|
|
686
|
+
|
|
687
|
+
await emitResult();
|
|
688
|
+
|
|
689
|
+
// No response should be posted
|
|
690
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalledWith(
|
|
691
|
+
"linear-session-1",
|
|
692
|
+
expect.objectContaining({ type: "response" }),
|
|
693
|
+
);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("handles assistant messages without message.content gracefully", async () => {
|
|
697
|
+
await createSessionAndSetupRelay();
|
|
698
|
+
|
|
699
|
+
// Assistant message with no content array
|
|
700
|
+
emitAssistant({ type: "assistant", message: {} });
|
|
701
|
+
emitAssistant({ type: "assistant" });
|
|
702
|
+
|
|
703
|
+
await emitResult();
|
|
704
|
+
|
|
705
|
+
// No text accumulated → no response
|
|
706
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalledWith(
|
|
707
|
+
"linear-session-1",
|
|
708
|
+
expect.objectContaining({ type: "response" }),
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("extracts tool use without input gracefully", async () => {
|
|
713
|
+
await createSessionAndSetupRelay();
|
|
714
|
+
|
|
715
|
+
emitAssistant({
|
|
716
|
+
type: "assistant",
|
|
717
|
+
message: {
|
|
718
|
+
content: [{ type: "tool_use", name: "Read" }],
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
723
|
+
expect.any(Object),
|
|
724
|
+
"linear-session-1",
|
|
725
|
+
expect.objectContaining({ type: "action", action: "Read" }),
|
|
726
|
+
expect.any(Function),
|
|
727
|
+
);
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
describe("relay — plan checklist (TodoWrite)", () => {
|
|
732
|
+
// Verifies that TodoWrite tool calls are intercepted and relayed as
|
|
733
|
+
// Linear plan/checklist updates via updateSessionPlan().
|
|
734
|
+
|
|
735
|
+
async function createSessionAndSetupRelay() {
|
|
736
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
737
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
738
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
739
|
+
vi.clearAllMocks();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function emitAssistant(msg: unknown) {
|
|
743
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: msg } as any);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function emitResult(msg: unknown = {}) {
|
|
747
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: msg } as any);
|
|
748
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
it("relays TodoWrite tool calls as Linear plan items", async () => {
|
|
752
|
+
await createSessionAndSetupRelay();
|
|
753
|
+
|
|
754
|
+
emitAssistant({
|
|
755
|
+
type: "assistant",
|
|
756
|
+
message: {
|
|
757
|
+
content: [{
|
|
758
|
+
type: "tool_use",
|
|
759
|
+
name: "TodoWrite",
|
|
760
|
+
input: {
|
|
761
|
+
todos: [
|
|
762
|
+
{ content: "Read the codebase", status: "completed", activeForm: "Reading codebase" },
|
|
763
|
+
{ content: "Fix the bug", status: "in_progress", activeForm: "Fixing bug" },
|
|
764
|
+
{ content: "Write tests", status: "pending", activeForm: "Writing tests" },
|
|
765
|
+
],
|
|
766
|
+
},
|
|
767
|
+
}],
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
expect(linearAgent.updateSessionPlan).toHaveBeenCalledWith(
|
|
772
|
+
expect.any(Object),
|
|
773
|
+
"linear-session-1",
|
|
774
|
+
[
|
|
775
|
+
{ content: "Read the codebase", status: "completed" },
|
|
776
|
+
{ content: "Fix the bug", status: "inProgress" },
|
|
777
|
+
{ content: "Write tests", status: "pending" },
|
|
778
|
+
],
|
|
779
|
+
expect.any(Function),
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("ignores TodoWrite with empty or invalid todos", async () => {
|
|
784
|
+
await createSessionAndSetupRelay();
|
|
785
|
+
|
|
786
|
+
// Empty todos array
|
|
787
|
+
emitAssistant({
|
|
788
|
+
type: "assistant",
|
|
789
|
+
message: {
|
|
790
|
+
content: [{
|
|
791
|
+
type: "tool_use",
|
|
792
|
+
name: "TodoWrite",
|
|
793
|
+
input: { todos: [] },
|
|
794
|
+
}],
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
expect(linearAgent.updateSessionPlan).not.toHaveBeenCalled();
|
|
799
|
+
|
|
800
|
+
// No todos key
|
|
801
|
+
emitAssistant({
|
|
802
|
+
type: "assistant",
|
|
803
|
+
message: {
|
|
804
|
+
content: [{
|
|
805
|
+
type: "tool_use",
|
|
806
|
+
name: "TodoWrite",
|
|
807
|
+
input: {},
|
|
808
|
+
}],
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(linearAgent.updateSessionPlan).not.toHaveBeenCalled();
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
describe("relay — tool results", () => {
|
|
817
|
+
// Verifies that tool_result content blocks are matched back to their
|
|
818
|
+
// corresponding tool_use and posted as action activities with result field.
|
|
819
|
+
|
|
820
|
+
async function createSessionAndSetupRelay() {
|
|
821
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
822
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
823
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
824
|
+
vi.clearAllMocks();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function emitAssistant(msg: unknown) {
|
|
828
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: msg } as any);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
it("posts tool result as action activity when tool_result block matches a pending tool_use", async () => {
|
|
832
|
+
await createSessionAndSetupRelay();
|
|
833
|
+
|
|
834
|
+
// First message: tool_use with an id
|
|
835
|
+
emitAssistant({
|
|
836
|
+
type: "assistant",
|
|
837
|
+
message: {
|
|
838
|
+
content: [
|
|
839
|
+
{ type: "tool_use", id: "tu_123", name: "Read", input: { file: "main.ts" } },
|
|
840
|
+
],
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
vi.clearAllMocks();
|
|
845
|
+
|
|
846
|
+
// Second message: tool_result matching the tool_use id
|
|
847
|
+
emitAssistant({
|
|
848
|
+
type: "assistant",
|
|
849
|
+
message: {
|
|
850
|
+
content: [
|
|
851
|
+
{ type: "tool_result", tool_use_id: "tu_123", content: "const x = 42;" },
|
|
852
|
+
],
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
857
|
+
expect.any(Object),
|
|
858
|
+
"linear-session-1",
|
|
859
|
+
expect.objectContaining({
|
|
860
|
+
type: "action",
|
|
861
|
+
action: "Read",
|
|
862
|
+
result: "const x = 42;",
|
|
863
|
+
}),
|
|
864
|
+
expect.any(Function),
|
|
865
|
+
);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("ignores tool_result blocks with no matching tool_use", async () => {
|
|
869
|
+
await createSessionAndSetupRelay();
|
|
870
|
+
|
|
871
|
+
// No preceding tool_use — just a tool_result with unknown id
|
|
872
|
+
emitAssistant({
|
|
873
|
+
type: "assistant",
|
|
874
|
+
message: {
|
|
875
|
+
content: [
|
|
876
|
+
{ type: "tool_result", tool_use_id: "unknown", content: "data" },
|
|
877
|
+
],
|
|
878
|
+
},
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Should not post any action result
|
|
882
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalled();
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe("relay — intermediate progress flush", () => {
|
|
887
|
+
// Verifies that accumulated text is periodically flushed as ephemeral
|
|
888
|
+
// thought activities so Linear doesn't look stalled during long sessions.
|
|
889
|
+
|
|
890
|
+
async function createSessionAndSetupRelay() {
|
|
891
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
892
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
893
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
894
|
+
vi.clearAllMocks();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function emitAssistant(msg: unknown) {
|
|
898
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: msg } as any);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function emitResult(msg: unknown = {}) {
|
|
902
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: msg } as any);
|
|
903
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
it("flushes accumulated text as ephemeral thought after 30 seconds", async () => {
|
|
907
|
+
await createSessionAndSetupRelay();
|
|
908
|
+
|
|
909
|
+
// Simulate text accumulation
|
|
910
|
+
emitAssistant({
|
|
911
|
+
type: "assistant",
|
|
912
|
+
message: { content: [{ type: "text", text: "Working on the fix..." }] },
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
vi.clearAllMocks();
|
|
916
|
+
|
|
917
|
+
// Advance time by 30 seconds to trigger the progress flush
|
|
918
|
+
vi.advanceTimersByTime(30_000);
|
|
919
|
+
|
|
920
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
921
|
+
expect.any(Object),
|
|
922
|
+
"linear-session-1",
|
|
923
|
+
expect.objectContaining({
|
|
924
|
+
type: "thought",
|
|
925
|
+
body: "Working on the fix...",
|
|
926
|
+
ephemeral: true,
|
|
927
|
+
}),
|
|
928
|
+
expect.any(Function),
|
|
929
|
+
);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("does not flush when no new text has accumulated since last flush", async () => {
|
|
933
|
+
await createSessionAndSetupRelay();
|
|
934
|
+
|
|
935
|
+
// Accumulate some text
|
|
936
|
+
emitAssistant({
|
|
937
|
+
type: "assistant",
|
|
938
|
+
message: { content: [{ type: "text", text: "First chunk" }] },
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
vi.clearAllMocks();
|
|
942
|
+
|
|
943
|
+
// First flush — should post
|
|
944
|
+
vi.advanceTimersByTime(30_000);
|
|
945
|
+
expect(linearAgent.postActivity).toHaveBeenCalledTimes(1);
|
|
946
|
+
|
|
947
|
+
vi.clearAllMocks();
|
|
948
|
+
|
|
949
|
+
// Second flush with no new text — should NOT post
|
|
950
|
+
vi.advanceTimersByTime(30_000);
|
|
951
|
+
expect(linearAgent.postActivity).not.toHaveBeenCalled();
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("resets flush state when turn completes", async () => {
|
|
955
|
+
await createSessionAndSetupRelay();
|
|
956
|
+
|
|
957
|
+
// Accumulate and flush
|
|
958
|
+
emitAssistant({
|
|
959
|
+
type: "assistant",
|
|
960
|
+
message: { content: [{ type: "text", text: "Before completion" }] },
|
|
961
|
+
});
|
|
962
|
+
vi.advanceTimersByTime(30_000);
|
|
963
|
+
vi.clearAllMocks();
|
|
964
|
+
|
|
965
|
+
// Turn completes — resets pendingText and lastFlushedLength
|
|
966
|
+
await emitResult();
|
|
967
|
+
|
|
968
|
+
// After completion, the timer interval has no new text to flush
|
|
969
|
+
vi.advanceTimersByTime(30_000);
|
|
970
|
+
|
|
971
|
+
// Only the response should have been posted, no extra thought
|
|
972
|
+
const thoughtCalls = vi.mocked(linearAgent.postActivity).mock.calls
|
|
973
|
+
.filter(([, , content]) => (content as { type: string }).type === "thought");
|
|
974
|
+
expect(thoughtCalls).toHaveLength(0);
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
describe("multi-turn conversation", () => {
|
|
979
|
+
// Verifies that after the first turn completes, the session mapping
|
|
980
|
+
// and relay stay alive so follow-up prompted events work correctly.
|
|
981
|
+
|
|
982
|
+
it("keeps session mapping alive after first turn completes", async () => {
|
|
983
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
984
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
985
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
986
|
+
|
|
987
|
+
vi.clearAllMocks();
|
|
988
|
+
// Re-mock getAgent after clearAllMocks (needed for credential lookup in setupRelay)
|
|
989
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(testAgent as ReturnType<typeof agentStore.getAgent>);
|
|
990
|
+
|
|
991
|
+
// Trigger turn completion via event bus
|
|
992
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: {} } as any);
|
|
993
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
994
|
+
|
|
995
|
+
// Now send a follow-up — should inject into existing session, NOT create new
|
|
996
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", "What about the tests?"));
|
|
997
|
+
|
|
998
|
+
expect(wsBridge.injectUserMessage).toHaveBeenCalledWith("comp-sess-1", "What about the tests?");
|
|
999
|
+
// Should NOT launch a new session
|
|
1000
|
+
expect(executor.executeAgent).not.toHaveBeenCalled();
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it("re-establishes relay on follow-up so responses are forwarded", async () => {
|
|
1004
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
1005
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
1006
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
1007
|
+
|
|
1008
|
+
// First turn: simulate response and turn completion via bus
|
|
1009
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: { type: "assistant", message: { content: [{ type: "text", text: "First response" }] } } } as any);
|
|
1010
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: {} } as any);
|
|
1011
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1012
|
+
|
|
1013
|
+
vi.clearAllMocks();
|
|
1014
|
+
// Re-mock getAgent after clearAllMocks (needed for credential lookup in setupRelay)
|
|
1015
|
+
vi.mocked(agentStore.getAgent).mockReturnValue(testAgent as ReturnType<typeof agentStore.getAgent>);
|
|
1016
|
+
|
|
1017
|
+
// Follow-up prompt — should re-establish relay
|
|
1018
|
+
await bridge.handleEvent(makePromptedEvent("linear-session-1", "Follow up"));
|
|
1019
|
+
|
|
1020
|
+
// setupRelay should have registered new listeners on the bus
|
|
1021
|
+
expect(companionBus.listenerCount("message:assistant")).toBeGreaterThan(0);
|
|
1022
|
+
expect(companionBus.listenerCount("message:result")).toBeGreaterThan(0);
|
|
1023
|
+
|
|
1024
|
+
// Simulate second turn response via bus
|
|
1025
|
+
vi.clearAllMocks();
|
|
1026
|
+
|
|
1027
|
+
companionBus.emit("message:assistant", { sessionId: "comp-sess-1", message: { type: "assistant", message: { content: [{ type: "text", text: "Second response" }] } } } as any);
|
|
1028
|
+
companionBus.emit("message:result", { sessionId: "comp-sess-1", message: {} } as any);
|
|
1029
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1030
|
+
|
|
1031
|
+
// The second response should be forwarded to Linear
|
|
1032
|
+
expect(linearAgent.postActivity).toHaveBeenCalledWith(
|
|
1033
|
+
expect.any(Object),
|
|
1034
|
+
"linear-session-1",
|
|
1035
|
+
expect.objectContaining({ type: "response", body: "Second response" }),
|
|
1036
|
+
expect.any(Function),
|
|
1037
|
+
);
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
describe("shutdown", () => {
|
|
1042
|
+
it("cleans up all session mappings and relay listeners", async () => {
|
|
1043
|
+
// Create a session
|
|
1044
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([testAgent] as ReturnType<typeof agentStore.listAgents>);
|
|
1045
|
+
vi.mocked(executor.executeAgent).mockResolvedValue({ sessionId: "comp-sess-1" } as never);
|
|
1046
|
+
|
|
1047
|
+
await bridge.handleEvent(makeCreatedEvent());
|
|
1048
|
+
|
|
1049
|
+
// Should have listeners registered
|
|
1050
|
+
const beforeAssistant = companionBus.listenerCount("message:assistant");
|
|
1051
|
+
const beforeResult = companionBus.listenerCount("message:result");
|
|
1052
|
+
expect(beforeAssistant).toBeGreaterThan(0);
|
|
1053
|
+
expect(beforeResult).toBeGreaterThan(0);
|
|
1054
|
+
|
|
1055
|
+
bridge.shutdown();
|
|
1056
|
+
|
|
1057
|
+
// After shutdown, listeners should have been removed
|
|
1058
|
+
expect(companionBus.listenerCount("message:assistant")).toBeLessThan(beforeAssistant);
|
|
1059
|
+
expect(companionBus.listenerCount("message:result")).toBeLessThan(beforeResult);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
describe("buildPrompt", () => {
|
|
1065
|
+
// Unit tests for the prompt enrichment function that prepends structured
|
|
1066
|
+
// issue context from the webhook payload before the XML promptContext.
|
|
1067
|
+
|
|
1068
|
+
it("prepends issue details when present", () => {
|
|
1069
|
+
const prompt = buildPrompt({
|
|
1070
|
+
action: "created",
|
|
1071
|
+
type: "AgentSessionEvent",
|
|
1072
|
+
agentSession: {
|
|
1073
|
+
id: "s1",
|
|
1074
|
+
status: "pending",
|
|
1075
|
+
createdAt: "",
|
|
1076
|
+
updatedAt: "",
|
|
1077
|
+
issue: {
|
|
1078
|
+
id: "i1",
|
|
1079
|
+
title: "Fix login",
|
|
1080
|
+
identifier: "APP-42",
|
|
1081
|
+
url: "https://linear.app/app/issue/APP-42",
|
|
1082
|
+
description: "Login page crashes",
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
promptContext: "<xml>data</xml>",
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
expect(prompt).toContain("[Linear Issue APP-42] Fix login");
|
|
1089
|
+
expect(prompt).toContain("URL: https://linear.app/app/issue/APP-42");
|
|
1090
|
+
expect(prompt).toContain("Login page crashes");
|
|
1091
|
+
expect(prompt).toContain("---");
|
|
1092
|
+
expect(prompt).toContain("<xml>data</xml>");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("includes comment body when present", () => {
|
|
1096
|
+
const prompt = buildPrompt({
|
|
1097
|
+
action: "created",
|
|
1098
|
+
type: "AgentSessionEvent",
|
|
1099
|
+
agentSession: {
|
|
1100
|
+
id: "s1",
|
|
1101
|
+
status: "pending",
|
|
1102
|
+
createdAt: "",
|
|
1103
|
+
updatedAt: "",
|
|
1104
|
+
comment: { id: "c1", body: "Please fix ASAP", userId: "u1", issueId: "i1" },
|
|
1105
|
+
},
|
|
1106
|
+
promptContext: "",
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
expect(prompt).toContain("User comment:\nPlease fix ASAP");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it("includes previous comments when present", () => {
|
|
1113
|
+
const prompt = buildPrompt({
|
|
1114
|
+
action: "created",
|
|
1115
|
+
type: "AgentSessionEvent",
|
|
1116
|
+
previousComments: [
|
|
1117
|
+
{ id: "c1", body: "First comment", userId: "u1", issueId: "i1" },
|
|
1118
|
+
{ id: "c2", body: "Second comment", userId: "u2", issueId: "i1" },
|
|
1119
|
+
],
|
|
1120
|
+
promptContext: "",
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
expect(prompt).toContain("Thread context (2 previous comments)");
|
|
1124
|
+
expect(prompt).toContain("- First comment");
|
|
1125
|
+
expect(prompt).toContain("- Second comment");
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("includes guidance when present", () => {
|
|
1129
|
+
const prompt = buildPrompt({
|
|
1130
|
+
action: "created",
|
|
1131
|
+
type: "AgentSessionEvent",
|
|
1132
|
+
guidance: "Always write tests",
|
|
1133
|
+
promptContext: "",
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
expect(prompt).toContain("Agent guidance:\nAlways write tests");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("returns raw promptContext when no structured data is present", () => {
|
|
1140
|
+
const prompt = buildPrompt({
|
|
1141
|
+
action: "created",
|
|
1142
|
+
type: "AgentSessionEvent",
|
|
1143
|
+
promptContext: "raw prompt context",
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
expect(prompt).toBe("raw prompt context");
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("returns empty string when nothing is provided", () => {
|
|
1150
|
+
const prompt = buildPrompt({
|
|
1151
|
+
action: "created",
|
|
1152
|
+
type: "AgentSessionEvent",
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
expect(prompt).toBe("");
|
|
1156
|
+
});
|
|
1157
|
+
});
|