alvin-bot 4.12.2 → 4.12.3
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 +64 -0
- package/dist/handlers/async-agent-chunk-handler.js +17 -0
- package/dist/handlers/background-bypass.js +75 -0
- package/dist/handlers/message.js +127 -16
- package/dist/services/async-agent-watcher.js +25 -0
- package/dist/services/session-persistence.js +5 -0
- package/dist/services/session.js +2 -0
- package/package.json +1 -1
- package/test/async-agent-chunk-flow.test.ts +113 -0
- package/test/background-bypass-integration.test.ts +443 -0
- package/test/background-bypass-stress.test.ts +417 -0
- package/test/background-bypass.test.ts +127 -0
- package/test/session-pending-background.test.ts +59 -0
- package/test/watcher-pending-count.test.ts +228 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.3 — async-agent watcher ↔ session.pendingBackgroundCount wiring.
|
|
3
|
+
*
|
|
4
|
+
* Contract:
|
|
5
|
+
* - registerPendingAgent takes an optional `sessionKey` so the watcher
|
|
6
|
+
* can locate the right UserSession later.
|
|
7
|
+
* - When the watcher delivers a result (completed/failed/timeout), the
|
|
8
|
+
* session's pendingBackgroundCount MUST be decremented so the main
|
|
9
|
+
* handler knows it's safe to resume SDK-session-based queries.
|
|
10
|
+
* - Decrement is clamped at 0 — the counter never goes negative even
|
|
11
|
+
* if decoupled operations drift.
|
|
12
|
+
* - The handler is responsible for INCREMENTING when it registers.
|
|
13
|
+
* The watcher only decrements.
|
|
14
|
+
*
|
|
15
|
+
* These tests use the shared in-memory session Map from session.ts so
|
|
16
|
+
* they exercise the actual wiring, not a mock.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import { resolve } from "path";
|
|
22
|
+
|
|
23
|
+
const TEST_DATA_DIR = resolve(
|
|
24
|
+
os.tmpdir(),
|
|
25
|
+
`alvin-watcher-pending-${process.pid}-${Date.now()}`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
30
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
33
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
36
|
+
deliverSubAgentResult: async () => {},
|
|
37
|
+
attachBotApi: () => {},
|
|
38
|
+
__setBotApiForTest: () => {},
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import("../src/services/async-agent-watcher.js");
|
|
45
|
+
mod.stopWatcher();
|
|
46
|
+
mod.__resetForTest();
|
|
47
|
+
} catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function writeCompletedJsonl(path: string, finalText: string): void {
|
|
53
|
+
const lines =
|
|
54
|
+
[
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
type: "user",
|
|
57
|
+
isSidechain: true,
|
|
58
|
+
agentId: "x",
|
|
59
|
+
message: { role: "user", content: "do it" },
|
|
60
|
+
}),
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
type: "assistant",
|
|
63
|
+
isSidechain: true,
|
|
64
|
+
agentId: "x",
|
|
65
|
+
message: {
|
|
66
|
+
role: "assistant",
|
|
67
|
+
content: [{ type: "text", text: finalText }],
|
|
68
|
+
stop_reason: "end_turn",
|
|
69
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
].join("\n") + "\n";
|
|
73
|
+
fs.mkdirSync(resolve(path, ".."), { recursive: true });
|
|
74
|
+
fs.writeFileSync(path, lines, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("watcher ↔ session.pendingBackgroundCount (v4.12.3)", () => {
|
|
78
|
+
it("completed delivery decrements pendingBackgroundCount on the right session", async () => {
|
|
79
|
+
const { getSession } = await import("../src/services/session.js");
|
|
80
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
81
|
+
|
|
82
|
+
const sessionKey = "v412-session-a";
|
|
83
|
+
const session = getSession(sessionKey);
|
|
84
|
+
session.pendingBackgroundCount = 1;
|
|
85
|
+
|
|
86
|
+
const outPath = `${TEST_DATA_DIR}/out-a.jsonl`;
|
|
87
|
+
watcher.registerPendingAgent({
|
|
88
|
+
agentId: "a",
|
|
89
|
+
outputFile: outPath,
|
|
90
|
+
description: "research",
|
|
91
|
+
prompt: "p",
|
|
92
|
+
chatId: 42,
|
|
93
|
+
userId: 42,
|
|
94
|
+
toolUseId: null,
|
|
95
|
+
sessionKey,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
writeCompletedJsonl(outPath, "result");
|
|
99
|
+
await watcher.pollOnce();
|
|
100
|
+
|
|
101
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("timeout delivery also decrements the counter", async () => {
|
|
105
|
+
const { getSession } = await import("../src/services/session.js");
|
|
106
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
107
|
+
|
|
108
|
+
const sessionKey = "v412-session-timeout";
|
|
109
|
+
const session = getSession(sessionKey);
|
|
110
|
+
session.pendingBackgroundCount = 2;
|
|
111
|
+
|
|
112
|
+
watcher.registerPendingAgent({
|
|
113
|
+
agentId: "timed-out",
|
|
114
|
+
outputFile: `${TEST_DATA_DIR}/never-written.jsonl`,
|
|
115
|
+
description: "slow task",
|
|
116
|
+
prompt: "p",
|
|
117
|
+
chatId: 42,
|
|
118
|
+
userId: 42,
|
|
119
|
+
toolUseId: null,
|
|
120
|
+
sessionKey,
|
|
121
|
+
giveUpAt: Date.now() - 1000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await watcher.pollOnce();
|
|
125
|
+
|
|
126
|
+
expect(session.pendingBackgroundCount).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("failure delivery decrements the counter", async () => {
|
|
130
|
+
const { getSession } = await import("../src/services/session.js");
|
|
131
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
132
|
+
|
|
133
|
+
const sessionKey = "v412-session-fail";
|
|
134
|
+
const session = getSession(sessionKey);
|
|
135
|
+
session.pendingBackgroundCount = 3;
|
|
136
|
+
|
|
137
|
+
const outPath = `${TEST_DATA_DIR}/fail.jsonl`;
|
|
138
|
+
// Write a malformed "error" state — a single invalid line that will
|
|
139
|
+
// fall through the parser and stay in "running" state. Then mark
|
|
140
|
+
// the session as a timeout by moving giveUpAt into the past.
|
|
141
|
+
// Actually easier: use giveUpAt again as the trigger.
|
|
142
|
+
watcher.registerPendingAgent({
|
|
143
|
+
agentId: "fail-via-timeout",
|
|
144
|
+
outputFile: outPath,
|
|
145
|
+
description: "will fail",
|
|
146
|
+
prompt: "p",
|
|
147
|
+
chatId: 42,
|
|
148
|
+
userId: 42,
|
|
149
|
+
toolUseId: null,
|
|
150
|
+
sessionKey,
|
|
151
|
+
giveUpAt: Date.now() - 1000,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await watcher.pollOnce();
|
|
155
|
+
|
|
156
|
+
expect(session.pendingBackgroundCount).toBe(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("decrement is clamped at 0 — counter never goes negative", async () => {
|
|
160
|
+
const { getSession } = await import("../src/services/session.js");
|
|
161
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
162
|
+
|
|
163
|
+
const sessionKey = "v412-session-drift";
|
|
164
|
+
const session = getSession(sessionKey);
|
|
165
|
+
session.pendingBackgroundCount = 0; // drift scenario
|
|
166
|
+
|
|
167
|
+
const outPath = `${TEST_DATA_DIR}/drift.jsonl`;
|
|
168
|
+
watcher.registerPendingAgent({
|
|
169
|
+
agentId: "drift",
|
|
170
|
+
outputFile: outPath,
|
|
171
|
+
description: "drift",
|
|
172
|
+
prompt: "p",
|
|
173
|
+
chatId: 42,
|
|
174
|
+
userId: 42,
|
|
175
|
+
toolUseId: null,
|
|
176
|
+
sessionKey,
|
|
177
|
+
});
|
|
178
|
+
writeCompletedJsonl(outPath, "done");
|
|
179
|
+
await watcher.pollOnce();
|
|
180
|
+
|
|
181
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("missing sessionKey is handled gracefully — no throw, no crash", async () => {
|
|
185
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
186
|
+
const outPath = `${TEST_DATA_DIR}/orphan.jsonl`;
|
|
187
|
+
watcher.registerPendingAgent({
|
|
188
|
+
agentId: "orphan",
|
|
189
|
+
outputFile: outPath,
|
|
190
|
+
description: "orphan",
|
|
191
|
+
prompt: "p",
|
|
192
|
+
chatId: 42,
|
|
193
|
+
userId: 42,
|
|
194
|
+
toolUseId: null,
|
|
195
|
+
// sessionKey intentionally omitted
|
|
196
|
+
});
|
|
197
|
+
writeCompletedJsonl(outPath, "done");
|
|
198
|
+
await expect(watcher.pollOnce()).resolves.not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("multiple agents for the same session all decrement", async () => {
|
|
202
|
+
const { getSession } = await import("../src/services/session.js");
|
|
203
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
204
|
+
|
|
205
|
+
const sessionKey = "v412-session-multi";
|
|
206
|
+
const session = getSession(sessionKey);
|
|
207
|
+
session.pendingBackgroundCount = 3;
|
|
208
|
+
|
|
209
|
+
for (const id of ["m1", "m2", "m3"]) {
|
|
210
|
+
const outPath = `${TEST_DATA_DIR}/${id}.jsonl`;
|
|
211
|
+
watcher.registerPendingAgent({
|
|
212
|
+
agentId: id,
|
|
213
|
+
outputFile: outPath,
|
|
214
|
+
description: `task ${id}`,
|
|
215
|
+
prompt: "p",
|
|
216
|
+
chatId: 42,
|
|
217
|
+
userId: 42,
|
|
218
|
+
toolUseId: null,
|
|
219
|
+
sessionKey,
|
|
220
|
+
});
|
|
221
|
+
writeCompletedJsonl(outPath, `result ${id}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await watcher.pollOnce();
|
|
225
|
+
|
|
226
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
});
|