@inetafrica/open-claudia 3.5.0 → 3.5.4
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 +32 -2
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +23 -5
- package/bin/owner-gate.js +2 -1
- package/bin/schedule.js +7 -2
- package/bin/tool.js +2 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/bot-endpoint.js +194 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +313 -126
- package/core/scheduler.js +64 -32
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/tool-guard.js +15 -0
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +15 -1
- package/test-provider-parity-e2e.js +8 -2
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
// Parallel conversations: the main timeline of a chat and each of its threads
|
|
6
|
+
// are separate LANES. End to end through router.onMessage, the real state,
|
|
7
|
+
// runner, handlers and scheduler, with the hermetic fake agent as the provider
|
|
8
|
+
// (it logs when each run starts and answers, with its OC_THREAD_ID/OC_PROJECT):
|
|
9
|
+
// - two threads run at the same time (their runs overlap);
|
|
10
|
+
// - a second message in one thread queues behind the first, then runs;
|
|
11
|
+
// - the main timeline runs beside a busy thread;
|
|
12
|
+
// - the per-speaker cap holds: the 5th conversation waits with a notice and
|
|
13
|
+
// starts when a slot frees;
|
|
14
|
+
// - /stop in thread X spares thread Y; "/stop all" stops every lane;
|
|
15
|
+
// - a wakeup made in thread X fires while Y runs and answers inside X;
|
|
16
|
+
// - typing and the live preview carry each lane's thread;
|
|
17
|
+
// - anyTurnActive; /compact acts on its own lane while another runs;
|
|
18
|
+
// - OC_THREAD_SEED=fork seeds a new thread from the main session;
|
|
19
|
+
// - the runtime state tells the agent about threads and parallel lanes.
|
|
20
|
+
// Plus the wiring around lanes: bounded thread state, room settings resolved
|
|
21
|
+
// from the turn's own context, the run attestation's thread, and injection
|
|
22
|
+
// dedupe per conversation.
|
|
23
|
+
|
|
24
|
+
const assert = require("assert");
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const os = require("os");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "oc-thread-lanes-"));
|
|
30
|
+
const configDir = path.join(root, "config");
|
|
31
|
+
const workspace = path.join(root, "workspace");
|
|
32
|
+
const runLog = path.join(root, "runs.jsonl");
|
|
33
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
34
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
35
|
+
const OWNER = "owner-chat-777";
|
|
36
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
37
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = configDir;
|
|
38
|
+
process.env.WORKSPACE = workspace;
|
|
39
|
+
process.env.TELEGRAM_CHAT_ID = OWNER;
|
|
40
|
+
process.env.CHANNELS = "";
|
|
41
|
+
process.env.PACK_REVIEW = "off";
|
|
42
|
+
process.env.PEOPLE_FILE = path.join(configDir, "people.json");
|
|
43
|
+
process.env.ENTITIES_DIR = path.join(root, "entities");
|
|
44
|
+
process.env.APPROVALS_DIR = path.join(root, "approvals");
|
|
45
|
+
delete process.env.OC_MAX_PARALLEL_TURNS;
|
|
46
|
+
delete process.env.OC_MAX_PARALLEL_TURNS_GLOBAL;
|
|
47
|
+
delete process.env.OC_THREAD_SEED;
|
|
48
|
+
fs.writeFileSync(process.env.PEOPLE_FILE, JSON.stringify([{
|
|
49
|
+
id: "person_owner", name: "Owner", isOwner: true,
|
|
50
|
+
handles: [{ adapter: "telegram", channelId: OWNER, canonicalUserId: `telegram:${OWNER}` }],
|
|
51
|
+
primaryChannel: { adapter: "telegram", channelId: OWNER }, notes: [],
|
|
52
|
+
}]));
|
|
53
|
+
|
|
54
|
+
const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
55
|
+
|
|
56
|
+
// ── Provider and prompt: the production runner with the fake agent CLI. The
|
|
57
|
+
// prompt builder is replaced by one that records the REAL runtime-state block.
|
|
58
|
+
const providers = require("./core/providers");
|
|
59
|
+
const { createClaudeProvider } = require("./core/providers/claude");
|
|
60
|
+
const provider = createClaudeProvider({
|
|
61
|
+
hookTransport: false,
|
|
62
|
+
resolveExecutable: () => fixture,
|
|
63
|
+
defaultModel: "claude-fixture-model",
|
|
64
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
65
|
+
buildEnv: () => ({
|
|
66
|
+
HOME: root,
|
|
67
|
+
PATH: process.env.PATH || path.dirname(process.execPath),
|
|
68
|
+
FAKE_AGENT_KIND: "claude",
|
|
69
|
+
FAKE_AGENT_RUN_LOG: runLog,
|
|
70
|
+
FAKE_AGENT_UNIQUE_SESSIONS: "1",
|
|
71
|
+
FAKE_AGENT_TOOL: "1",
|
|
72
|
+
FAKE_AGENT_NARRATION: "Looking into it first",
|
|
73
|
+
FAKE_AGENT_TEXT: "fixture answer",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
providers.resolveProviderForRun = () => provider;
|
|
77
|
+
const systemPrompt = require("./core/system-prompt");
|
|
78
|
+
const runtimeBlocks = new Map(); // tag -> runtime-state block the run was given
|
|
79
|
+
systemPrompt.buildTurnPrompt = async (prompt, opts = {}) => {
|
|
80
|
+
const tag = /\[\[tag:([\w-]+)\]\]/.exec(String(prompt))?.[1];
|
|
81
|
+
let dynamicContext = "";
|
|
82
|
+
try { dynamicContext = systemPrompt.buildDynamicContextBlock(opts); } catch (error) { dynamicContext = `runtime failed: ${error.message}`; }
|
|
83
|
+
// First prompt per tag only: a compaction seed replays recent turns (tags included).
|
|
84
|
+
if (tag && !runtimeBlocks.has(tag)) runtimeBlocks.set(tag, dynamicContext);
|
|
85
|
+
return { coreInstructions: "Fixture core", dynamicContext: "", userPrompt: prompt, transcriptPaths: {}, recallMetadata: {} };
|
|
86
|
+
};
|
|
87
|
+
require("./core/turn-observer").createTurnObserver = () => ({ onRecallSurfaced() {}, onToolStart() {}, onTurnSettled() {} });
|
|
88
|
+
|
|
89
|
+
const stateApi = require("./core/state");
|
|
90
|
+
const router = require("./core/router");
|
|
91
|
+
require("./core/handlers");
|
|
92
|
+
const scheduler = require("./core/scheduler");
|
|
93
|
+
const { conversationKeyFor } = require("./core/identity");
|
|
94
|
+
|
|
95
|
+
// ── A chat adapter that records everything, with the thread each call carries.
|
|
96
|
+
const events = [];
|
|
97
|
+
let nextMessageId = 1000;
|
|
98
|
+
const adapter = {
|
|
99
|
+
id: "telegram",
|
|
100
|
+
type: "telegram",
|
|
101
|
+
async send(channelId, text, opts = {}) {
|
|
102
|
+
const id = String(nextMessageId++);
|
|
103
|
+
events.push({ kind: "send", id, channelId, text: String(text), threadId: opts.threadId || null, at: Date.now() });
|
|
104
|
+
return id;
|
|
105
|
+
},
|
|
106
|
+
async edit(channelId, messageId, text, opts = {}) {
|
|
107
|
+
events.push({ kind: "edit", id: String(messageId), channelId, text: String(text), streaming: !!opts.streaming, at: Date.now() });
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
async delete(channelId, messageId) {
|
|
111
|
+
events.push({ kind: "delete", id: String(messageId), channelId, at: Date.now() });
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
async typing(channelId, state, opts = {}) {
|
|
115
|
+
events.push({ kind: "typing", channelId, state: state || "typing", threadId: opts.threadId || null, at: Date.now() });
|
|
116
|
+
},
|
|
117
|
+
async typingStop(channelId, opts = {}) {
|
|
118
|
+
events.push({ kind: "typingStop", channelId, threadId: opts.threadId || null, at: Date.now() });
|
|
119
|
+
},
|
|
120
|
+
async downloadMedia() { throw new Error("no media in this fixture"); },
|
|
121
|
+
};
|
|
122
|
+
scheduler.setAdapters([adapter]);
|
|
123
|
+
|
|
124
|
+
const T = (n) => String(n).repeat(24).slice(0, 24); // a 24-hex Kazee-style root id
|
|
125
|
+
const FORK_THREAD = `3${"2".repeat(23)}`;
|
|
126
|
+
const FRESH_THREAD = `5${"4".repeat(23)}`;
|
|
127
|
+
const MAIN_KEY = conversationKeyFor("telegram", OWNER);
|
|
128
|
+
const keyOf = (threadId) => conversationKeyFor("telegram", OWNER, threadId);
|
|
129
|
+
let inbound = 0;
|
|
130
|
+
function message(text, threadId = null) {
|
|
131
|
+
inbound += 1;
|
|
132
|
+
return router.onMessage({
|
|
133
|
+
adapter,
|
|
134
|
+
channelId: OWNER,
|
|
135
|
+
threadId,
|
|
136
|
+
canonicalUserId: `telegram:${OWNER}`,
|
|
137
|
+
userId: OWNER,
|
|
138
|
+
type: "text",
|
|
139
|
+
text,
|
|
140
|
+
messageId: `in-${inbound}`,
|
|
141
|
+
from: { name: "Owner" },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function runs() {
|
|
146
|
+
if (!fs.existsSync(runLog)) return [];
|
|
147
|
+
return fs.readFileSync(runLog, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
148
|
+
}
|
|
149
|
+
function runOf(tag) {
|
|
150
|
+
const all = runs().filter((r) => r.tag === tag);
|
|
151
|
+
return { start: all.find((r) => r.event === "start"), end: all.find((r) => r.event === "end") };
|
|
152
|
+
}
|
|
153
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
154
|
+
async function until(predicate, label, timeoutMs = 15000) {
|
|
155
|
+
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
if (predicate()) return;
|
|
158
|
+
await sleep(25);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
161
|
+
}
|
|
162
|
+
const state = () => stateApi.getUserState(`telegram:${OWNER}`);
|
|
163
|
+
const lane = (key) => stateApi.peekLane(state(), key);
|
|
164
|
+
const sendsIn = (threadId, since = 0) => events.filter((e) => e.kind === "send" && e.threadId === threadId && e.at >= since);
|
|
165
|
+
async function idle() {
|
|
166
|
+
await until(() => !stateApi.anyTurnActive(), "every lane to go idle");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function twoThreadsRunConcurrently() {
|
|
170
|
+
const [a, b] = [T(1), T(2)];
|
|
171
|
+
const since = Date.now();
|
|
172
|
+
await Promise.all([
|
|
173
|
+
message("[[tag:par-a]] [[delay:1200]] first thread", a),
|
|
174
|
+
message("[[tag:par-b]] [[delay:1200]] second thread", b),
|
|
175
|
+
]);
|
|
176
|
+
const ra = runOf("par-a");
|
|
177
|
+
const rb = runOf("par-b");
|
|
178
|
+
assert.ok(ra.start && ra.end && rb.start && rb.end, "both thread runs ran to the end");
|
|
179
|
+
assert.ok(rb.start.at < ra.end.at && ra.start.at < rb.end.at, "the two threads' runs overlap in time");
|
|
180
|
+
assert.strictEqual(ra.start.threadId, a, "the run knows its thread (OC_THREAD_ID)");
|
|
181
|
+
assert.strictEqual(rb.start.threadId, b);
|
|
182
|
+
assert.strictEqual(ra.start.project, keyOf(a), "each thread is its own conversation (OC_PROJECT)");
|
|
183
|
+
assert.strictEqual(rb.start.project, keyOf(b));
|
|
184
|
+
assert.ok(sendsIn(a, since).some((e) => e.text.includes("fixture answer")), "thread A's answer goes into thread A");
|
|
185
|
+
assert.ok(sendsIn(b, since).some((e) => e.text.includes("fixture answer")), "thread B's answer goes into thread B");
|
|
186
|
+
assert.ok(!sendsIn(null, since).some((e) => e.text.includes("fixture answer")), "nothing lands in the main timeline");
|
|
187
|
+
const sessions = state().activeSessions;
|
|
188
|
+
assert.ok(sessions[keyOf(a)]?.claude && sessions[keyOf(b)]?.claude, "each thread keeps its own provider session");
|
|
189
|
+
assert.notStrictEqual(sessions[keyOf(a)].claude, sessions[keyOf(b)].claude);
|
|
190
|
+
await idle();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function sameThreadQueuesThenRuns() {
|
|
194
|
+
const a = T(1);
|
|
195
|
+
const since = Date.now();
|
|
196
|
+
const first = message("[[tag:q-1]] [[delay:1000]] long one", a);
|
|
197
|
+
await until(() => lane(keyOf(a))?.runningProcess, "the first run to spawn");
|
|
198
|
+
const second = message("[[tag:q-2]] quick follow-up", a);
|
|
199
|
+
await until(() => sendsIn(a, since).some((e) => e.text === "Queued."), "the queued notice");
|
|
200
|
+
assert.strictEqual(lane(keyOf(a)).messageQueue.length, 1, "the follow-up waits in its thread's lane");
|
|
201
|
+
await Promise.all([first, second]);
|
|
202
|
+
const r1 = runOf("q-1");
|
|
203
|
+
const r2 = runOf("q-2");
|
|
204
|
+
assert.ok(r2.start.at >= r1.end.at, "inside one conversation turns run one at a time");
|
|
205
|
+
assert.strictEqual(r2.start.threadId, a);
|
|
206
|
+
await idle();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function mainLaneUnaffectedByThread() {
|
|
210
|
+
const a = T(3);
|
|
211
|
+
const since = Date.now();
|
|
212
|
+
const long = message("[[tag:bg-thread]] [[delay:1500]] busy thread", a);
|
|
213
|
+
await until(() => lane(keyOf(a))?.runningProcess, "the thread run to spawn");
|
|
214
|
+
await message("[[tag:bg-main]] quick main question");
|
|
215
|
+
const main = runOf("bg-main");
|
|
216
|
+
assert.ok(main.end, "the main timeline answered");
|
|
217
|
+
assert.ok(!runOf("bg-thread").end, "while the thread was still running");
|
|
218
|
+
assert.strictEqual(main.start.threadId, null, "a main-timeline run has no thread");
|
|
219
|
+
assert.strictEqual(main.start.project, MAIN_KEY);
|
|
220
|
+
assert.ok(!sendsIn(null, since).some((e) => e.text === "Queued."), "the main message never queued behind the thread");
|
|
221
|
+
await long;
|
|
222
|
+
await idle();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function capHoldsAndFifthWaits() {
|
|
226
|
+
assert.strictEqual(stateApi.maxParallelTurns(), 4, "default cap");
|
|
227
|
+
const threads = [T(4), T(5), T(6)];
|
|
228
|
+
const since = Date.now();
|
|
229
|
+
const running = [
|
|
230
|
+
message("[[tag:cap-main]] [[delay:1500]] main"),
|
|
231
|
+
...threads.map((t, i) => message(`[[tag:cap-${i}]] [[delay:1500]] thread ${i}`, t)),
|
|
232
|
+
];
|
|
233
|
+
await until(() => stateApi.runningLanes(state()).length === 4, "four running lanes");
|
|
234
|
+
const fifth = T(7);
|
|
235
|
+
const waiting = message("[[tag:cap-fifth]] fifth conversation", fifth);
|
|
236
|
+
await until(() => sendsIn(fifth, since).some((e) => e.text.startsWith("Waiting:")), "the waiting notice");
|
|
237
|
+
const notice = sendsIn(fifth, since).find((e) => e.text.startsWith("Waiting:"));
|
|
238
|
+
assert.strictEqual(notice.text, "Waiting: 4 conversations are already running; this starts when one finishes.");
|
|
239
|
+
assert.strictEqual(stateApi.runningLanes(state()).length, 4, "the cap holds");
|
|
240
|
+
const statusSince = Date.now();
|
|
241
|
+
await message("/status");
|
|
242
|
+
const status = sendsIn(null, statusSince).find((e) => e.text.includes("Running conversations:"));
|
|
243
|
+
assert.ok(status, "/status lists the running conversations");
|
|
244
|
+
assert.match(status.text, /Running conversations: 4 \(1 waiting for a slot\)/);
|
|
245
|
+
assert.match(status.text, /\n- main: ".*main"/);
|
|
246
|
+
assert.match(status.text, /\n- thread …444444: ".*thread 0"/);
|
|
247
|
+
assert.ok(stateApi.anyTurnActive(), "anyTurnActive while lanes run");
|
|
248
|
+
await Promise.all([...running, waiting]);
|
|
249
|
+
const firstEnd = Math.min(...["cap-main", "cap-0", "cap-1", "cap-2"].map((tag) => runOf(tag).end.at));
|
|
250
|
+
assert.ok(runOf("cap-fifth").start.at >= firstEnd, "the fifth starts only once a slot frees");
|
|
251
|
+
assert.strictEqual(runOf("cap-fifth").start.threadId, fifth);
|
|
252
|
+
await idle();
|
|
253
|
+
assert.strictEqual(stateApi.anyTurnActive(), false, "anyTurnActive is false once every lane is idle");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function stopInOneThreadSparesTheOther() {
|
|
257
|
+
const [x, y] = [T(8), T(9)];
|
|
258
|
+
const since = Date.now();
|
|
259
|
+
const inX = message("[[tag:stop-x]] [[delay:8000]] long x", x);
|
|
260
|
+
const inY = message("[[tag:stop-y]] [[delay:1500]] y", y);
|
|
261
|
+
await until(() => lane(keyOf(x))?.runningProcess && lane(keyOf(y))?.runningProcess, "both runs to spawn");
|
|
262
|
+
await message("/stop", x);
|
|
263
|
+
assert.ok(sendsIn(x, since).some((e) => e.text === "Cancelled."), "/stop answers in thread X");
|
|
264
|
+
await inX;
|
|
265
|
+
assert.ok(!runOf("stop-x").end, "thread X's run was stopped");
|
|
266
|
+
await inY;
|
|
267
|
+
assert.ok(runOf("stop-y").end, "thread Y ran to the end");
|
|
268
|
+
assert.ok(sendsIn(y, since).some((e) => e.text.includes("fixture answer")), "and delivered its answer");
|
|
269
|
+
assert.ok(!sendsIn(x, since).some((e) => e.text.includes("fixture answer")), "X delivered nothing after /stop");
|
|
270
|
+
assert.ok(events.some((e) => e.kind === "typingStop" && e.threadId === x && e.at >= since), "X's typing stopped inside X");
|
|
271
|
+
await idle();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function stopAllStopsEveryLane() {
|
|
275
|
+
const a = T("a");
|
|
276
|
+
const since = Date.now();
|
|
277
|
+
const inMain = message("[[tag:all-main]] [[delay:8000]] main");
|
|
278
|
+
const inThread = message("[[tag:all-thread]] [[delay:8000]] thread", a);
|
|
279
|
+
await until(() => lane(MAIN_KEY)?.runningProcess && lane(keyOf(a))?.runningProcess, "both runs to spawn");
|
|
280
|
+
await message("/stop all");
|
|
281
|
+
assert.ok(sendsIn(null, since).some((e) => e.text === "Cancelled 2 running conversations."), "/stop all reports both");
|
|
282
|
+
await Promise.all([inMain, inThread]);
|
|
283
|
+
assert.ok(!runOf("all-main").end && !runOf("all-thread").end, "neither run finished");
|
|
284
|
+
await idle();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function wakeupFiresIntoItsThread() {
|
|
288
|
+
const [x, y] = [T("b"), T("c")];
|
|
289
|
+
const since = Date.now();
|
|
290
|
+
const inY = message("[[tag:wake-y]] [[delay:2500]] y keeps running", y);
|
|
291
|
+
await until(() => lane(keyOf(y))?.runningProcess, "thread Y's run to spawn");
|
|
292
|
+
const job = scheduler.addJob({
|
|
293
|
+
kind: "wakeup",
|
|
294
|
+
adapter: "telegram",
|
|
295
|
+
adapterType: "telegram",
|
|
296
|
+
channelId: OWNER,
|
|
297
|
+
canonicalUserId: `telegram:${OWNER}`,
|
|
298
|
+
userId: OWNER,
|
|
299
|
+
provider: "claude",
|
|
300
|
+
project: keyOf(x),
|
|
301
|
+
threadId: x,
|
|
302
|
+
sessionId: null,
|
|
303
|
+
label: "thread wake",
|
|
304
|
+
prompt: "[[tag:wake-x]] wake up in x",
|
|
305
|
+
fireAt: Date.now() + 60 * 60 * 1000,
|
|
306
|
+
});
|
|
307
|
+
const fired = await scheduler.fireJob(job.id);
|
|
308
|
+
assert.strictEqual(fired.ok, true, `the wakeup ran: ${JSON.stringify(fired.error || fired.status)}`);
|
|
309
|
+
const wake = runOf("wake-x");
|
|
310
|
+
assert.ok(wake.end, "the wakeup's run answered");
|
|
311
|
+
assert.ok(!runOf("wake-y").end, "while thread Y was still running");
|
|
312
|
+
assert.strictEqual(wake.start.threadId, x, "the wakeup's run is in thread X");
|
|
313
|
+
assert.strictEqual(wake.start.project, keyOf(x));
|
|
314
|
+
assert.ok(sendsIn(x, since).some((e) => e.text.startsWith("Wakeup: thread wake")), "the wakeup notice lands in thread X");
|
|
315
|
+
assert.ok(sendsIn(x, since).some((e) => e.text.includes("fixture answer")), "and so does its answer");
|
|
316
|
+
await inY;
|
|
317
|
+
await idle();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function typingAndPreviewCarryTheirThread() {
|
|
321
|
+
const [p, q] = [T("d"), T("e")];
|
|
322
|
+
const since = Date.now();
|
|
323
|
+
await Promise.all([
|
|
324
|
+
message("[[tag:pv-p]] [[chunkdelay:1100]] preview p", p),
|
|
325
|
+
message("[[tag:pv-q]] [[chunkdelay:1100]] preview q", q),
|
|
326
|
+
]);
|
|
327
|
+
for (const thread of [p, q]) {
|
|
328
|
+
assert.ok(events.some((e) => e.kind === "typing" && e.threadId === thread && e.at >= since), `typing goes into thread ${thread}`);
|
|
329
|
+
const preview = sendsIn(thread, since).find((e) => e.text.includes("⏳ working"));
|
|
330
|
+
assert.ok(preview, `the live preview of thread ${thread} is posted into it`);
|
|
331
|
+
// Every edit/delete of that preview message stays with that message.
|
|
332
|
+
const touches = events.filter((e) => (e.kind === "edit" || e.kind === "delete") && e.id === preview.id);
|
|
333
|
+
assert.ok(touches.length >= 1, "the preview is settled when the run ends");
|
|
334
|
+
}
|
|
335
|
+
const previews = [p, q].map((thread) => sendsIn(thread, since).find((e) => e.text.includes("⏳ working")).id);
|
|
336
|
+
assert.notStrictEqual(previews[0], previews[1], "each run owns its own preview message");
|
|
337
|
+
assert.ok(!events.some((e) => e.kind === "typing" && e.threadId === null && e.at >= since), "no typing leaks into the main timeline");
|
|
338
|
+
await idle();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function compactionActsOnItsOwnLane() {
|
|
342
|
+
const [x, y] = [T("f"), T(1)];
|
|
343
|
+
assert.ok(state().activeSessions[keyOf(y)]?.claude, "thread has a session to compact");
|
|
344
|
+
const since = Date.now();
|
|
345
|
+
const inX = message("[[tag:cmp-x]] [[delay:3000]] keep x busy", x);
|
|
346
|
+
await until(() => lane(keyOf(x))?.runningProcess, "thread X's run to spawn");
|
|
347
|
+
const before = state().activeSessions[keyOf(y)].claude;
|
|
348
|
+
await message("/compact", y);
|
|
349
|
+
const reply = sendsIn(y, since).find((e) => /Compacted into a fresh session|Could not compact|Compaction failed/.test(e.text));
|
|
350
|
+
assert.ok(reply && reply.text.startsWith("Compacted into a fresh session"), `compaction of thread Y: ${reply?.text}`);
|
|
351
|
+
assert.ok(!runOf("cmp-x").end, "thread X was still running: compaction never waited for it");
|
|
352
|
+
assert.notStrictEqual(state().activeSessions[keyOf(y)].claude, before, "thread Y moved to its compacted session");
|
|
353
|
+
assert.ok(lane(keyOf(x))?.runningProcess, "and X's lane was untouched");
|
|
354
|
+
await inX;
|
|
355
|
+
await idle();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function forkSeedsANewThreadFromMain() {
|
|
359
|
+
const mainSession = state().activeSessions[MAIN_KEY]?.claude;
|
|
360
|
+
assert.ok(mainSession, "the main timeline has a session");
|
|
361
|
+
process.env.OC_THREAD_SEED = "fork";
|
|
362
|
+
try {
|
|
363
|
+
await message("[[tag:fork-new]] start a forked thread", FORK_THREAD);
|
|
364
|
+
} finally {
|
|
365
|
+
delete process.env.OC_THREAD_SEED;
|
|
366
|
+
}
|
|
367
|
+
const fork = runOf("fork-new");
|
|
368
|
+
assert.strictEqual(fork.start.fork, true, "the first turn of a thread resumes main with --fork-session");
|
|
369
|
+
assert.strictEqual(fork.start.resume, mainSession, "it resumes (forks) the main session");
|
|
370
|
+
// …but the turn's own session is not the main one: jobs and approvals made
|
|
371
|
+
// in it must never carry the main id.
|
|
372
|
+
assert.notStrictEqual(fork.start.sessionEnv, mainSession, "OC_SESSION_ID is never the main session in a fork turn");
|
|
373
|
+
assert.notStrictEqual(fork.start.lastSessionEnv, mainSession, "nor is OC_LAST_SESSION_ID");
|
|
374
|
+
const threadKey = keyOf(FORK_THREAD);
|
|
375
|
+
const threadSession = state().activeSessions[threadKey]?.claude;
|
|
376
|
+
assert.ok(threadSession && threadSession !== mainSession, "the thread got a session of its own");
|
|
377
|
+
assert.strictEqual(state().activeSessions[MAIN_KEY].claude, mainSession, "the main session is untouched");
|
|
378
|
+
await message("[[tag:fork-again]] second turn", FORK_THREAD);
|
|
379
|
+
assert.strictEqual(runOf("fork-again").start.fork, false, "later turns resume the thread's own session");
|
|
380
|
+
await message("[[tag:fresh-default]] default seed", FRESH_THREAD);
|
|
381
|
+
assert.strictEqual(runOf("fresh-default").start.fork, false, "the default is a fresh thread");
|
|
382
|
+
await idle();
|
|
383
|
+
|
|
384
|
+
// A thread job that stored the MAIN session id (an older build's fork turn)
|
|
385
|
+
// never resumes it: it resumes the thread's own session, or runs fresh when
|
|
386
|
+
// the thread has none, and never pins the thread to the main session.
|
|
387
|
+
const wake = (threadId, tag) => scheduler.addJob({
|
|
388
|
+
kind: "wakeup", adapter: "telegram", adapterType: "telegram", channelId: OWNER,
|
|
389
|
+
canonicalUserId: `telegram:${OWNER}`, userId: OWNER, provider: "claude",
|
|
390
|
+
project: keyOf(threadId), threadId, sessionId: mainSession, label: "main id wake",
|
|
391
|
+
prompt: `[[tag:${tag}]] wake`, fireAt: Date.now() + 60 * 60 * 1000,
|
|
392
|
+
});
|
|
393
|
+
const ownSession = state().activeSessions[threadKey].claude;
|
|
394
|
+
assert.strictEqual((await scheduler.fireJob(wake(FORK_THREAD, "wake-main-id").id)).ok, true);
|
|
395
|
+
assert.strictEqual(runOf("wake-main-id").start.resume, ownSession, "the job resumes the thread's own session, not main");
|
|
396
|
+
const orphan = `7${"6".repeat(23)}`;
|
|
397
|
+
assert.strictEqual((await scheduler.fireJob(wake(orphan, "wake-orphan").id)).ok, true);
|
|
398
|
+
assert.strictEqual(runOf("wake-orphan").start.resume, null, "a thread with no session of its own runs fresh");
|
|
399
|
+
assert.notStrictEqual(state().activeSessions[keyOf(orphan)]?.claude, mainSession, "and is not pinned to the main session");
|
|
400
|
+
assert.strictEqual(state().activeSessions[MAIN_KEY].claude, mainSession, "the main session is untouched");
|
|
401
|
+
await idle();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function runtimeStateTellsTheAgent() {
|
|
405
|
+
const par = runtimeBlocks.get("par-a") || "";
|
|
406
|
+
assert.match(par, /Thread: this conversation is a Kazee thread \(replies go into the thread\)/);
|
|
407
|
+
// Whichever of the two threads built its prompt second saw the other running.
|
|
408
|
+
const parallel = [runtimeBlocks.get("par-a") || "", runtimeBlocks.get("par-b") || ""]
|
|
409
|
+
.join("\n").match(/- Parallel conversations running now: .*/g) || [];
|
|
410
|
+
assert.ok(parallel.some((line) => /running now: 1 \(thread …(1{6}|2{6}): (first thread|second thread)\)/.test(line)),
|
|
411
|
+
`a thread is told the other thread is running: ${parallel.join(" | ")}`);
|
|
412
|
+
assert.ok(parallel.every((line) => line.endsWith("Do not edit a git checkout another conversation is using; make your own worktree.")));
|
|
413
|
+
const main = runtimeBlocks.get("bg-main") || "";
|
|
414
|
+
assert.doesNotMatch(main, /Thread: this conversation/, "the main timeline is not a thread");
|
|
415
|
+
assert.match(main, /Parallel conversations running now: 1 \(thread …3{6}: busy thread\)/);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function wiringAndBounds() {
|
|
419
|
+
const { runInChat } = require("./core/context");
|
|
420
|
+
const runner = require("./core/runner");
|
|
421
|
+
const attest = require("./core/speaker-attest");
|
|
422
|
+
|
|
423
|
+
// activeSessions keeps at most MAX_THREAD_SESSIONS thread pointers, oldest
|
|
424
|
+
// written first out; channel keys and busy threads are never evicted.
|
|
425
|
+
const s = { userId: "growth", activeSessions: { [MAIN_KEY]: { claude: "main" } } };
|
|
426
|
+
const max = stateApi.MAX_THREAD_SESSIONS;
|
|
427
|
+
const threadKey = (n) => keyOf(`${String(n).padStart(24, "0")}`);
|
|
428
|
+
stateApi.laneFor(s, threadKey(0)).runningProcess = { pid: 1 };
|
|
429
|
+
for (let n = 0; n < max + 5; n += 1) stateApi.setProviderSession(s, "claude", threadKey(n), `t-${n}`);
|
|
430
|
+
const threadKeys = Object.keys(s.activeSessions).filter((key) => key.includes("#thread:"));
|
|
431
|
+
assert.strictEqual(threadKeys.length, max, "thread session pointers are bounded");
|
|
432
|
+
assert.ok(s.activeSessions[MAIN_KEY], "the channel's own session is never evicted");
|
|
433
|
+
assert.ok(s.activeSessions[threadKey(0)], "a running thread is never evicted");
|
|
434
|
+
assert.ok(!s.activeSessions[threadKey(1)], "the least recently used idle thread goes first");
|
|
435
|
+
assert.ok(s.activeSessions[threadKey(max + 4)], "the newest thread stays");
|
|
436
|
+
s.lanes.clear();
|
|
437
|
+
|
|
438
|
+
// usageBySession is partitioned: threads never push channels' usage out.
|
|
439
|
+
const u = { userId: "usage", usageBySession: {} };
|
|
440
|
+
for (let n = 0; n < 10; n += 1) stateApi.getUsageForSession(u, `telegram:chan-${n}`, "claude", `s-${n}`);
|
|
441
|
+
for (let n = 0; n < stateApi.MAX_USAGE_ENTRIES + 20; n += 1) stateApi.getUsageForSession(u, threadKey(n), "claude", `t-${n}`);
|
|
442
|
+
const usageKeys = Object.keys(u.usageBySession);
|
|
443
|
+
assert.strictEqual(usageKeys.filter((key) => !key.includes("#thread:")).length, 10, "every channel keeps its usage record");
|
|
444
|
+
assert.strictEqual(usageKeys.filter((key) => key.includes("#thread:")).length, stateApi.MAX_USAGE_ENTRIES + 20, "threads have a budget of their own");
|
|
445
|
+
|
|
446
|
+
// Room settings come from the turn's own context, never from a stamp on the
|
|
447
|
+
// shared state: one speaker answering in two rooms at once keeps them apart,
|
|
448
|
+
// and a thread's room is its channel.
|
|
449
|
+
stateApi.setRoomSetting("telegram:room-a", "backend", "claude");
|
|
450
|
+
stateApi.setRoomSetting("telegram:room-b", "backend", "codex");
|
|
451
|
+
const speaker = stateApi.getUserState("telegram:two-rooms");
|
|
452
|
+
const inRoom = (room, threadId) => runInChat({
|
|
453
|
+
channelId: room, canonicalUserId: "telegram:two-rooms",
|
|
454
|
+
conversationKey: conversationKeyFor("telegram", room, threadId), roomKey: conversationKeyFor("telegram", room),
|
|
455
|
+
}, async () => {
|
|
456
|
+
stateApi.currentState();
|
|
457
|
+
await sleep(20);
|
|
458
|
+
return stateApi.getActiveProvider(speaker);
|
|
459
|
+
});
|
|
460
|
+
const [a, b] = await Promise.all([inRoom("room-a"), inRoom("room-b", T(6))]);
|
|
461
|
+
assert.deepStrictEqual([a, b], ["claude", "codex"], "each concurrent turn resolves its own room");
|
|
462
|
+
const threadRoom = runInChat({ channelId: "room-b", conversationKey: conversationKeyFor("telegram", "room-b", T(6)) },
|
|
463
|
+
() => stateApi.activeRoomKey());
|
|
464
|
+
assert.strictEqual(threadRoom, "telegram:room-b", "a thread without an explicit room resolves to its channel's room");
|
|
465
|
+
assert.strictEqual(speaker.roomKey, undefined, "nothing is stamped on the state");
|
|
466
|
+
|
|
467
|
+
// The run attestation names the thread, and the run's env carries it.
|
|
468
|
+
const runContext = {
|
|
469
|
+
runId: "attest-thread-run", purpose: "foreground", provider: "claude", channelId: OWNER, threadId: T(7),
|
|
470
|
+
canonicalUserId: `telegram:${OWNER}`, project: { name: keyOf(T(7)), dir: workspace },
|
|
471
|
+
origin: { adapterId: "telegram", transport: "telegram", channelId: OWNER, userId: OWNER, canonicalUserId: `telegram:${OWNER}` },
|
|
472
|
+
};
|
|
473
|
+
assert.strictEqual(runner.runPrincipal(runContext).threadId, T(7));
|
|
474
|
+
const env = runner.decorateForegroundInvocation({ env: {} }, runContext, { transcriptPaths: null }).env;
|
|
475
|
+
assert.strictEqual(env.OC_THREAD_ID, T(7), "OC_THREAD_ID is set for a thread turn");
|
|
476
|
+
assert.strictEqual(attest.verifyRun("attest-thread-run", env.OC_RUN_ATTEST).threadId, T(7), "the attested principal carries the thread");
|
|
477
|
+
attest.endRun("attest-thread-run");
|
|
478
|
+
const mainEnv = runner.decorateForegroundInvocation({ env: {} }, { ...runContext, runId: "attest-main", threadId: null }, { transcriptPaths: null }).env;
|
|
479
|
+
assert.ok(!("OC_THREAD_ID" in mainEnv), "no OC_THREAD_ID on the main timeline");
|
|
480
|
+
assert.strictEqual(attest.verifyRun("attest-main", mainEnv.OC_RUN_ATTEST).threadId, null);
|
|
481
|
+
attest.endRun("attest-main");
|
|
482
|
+
|
|
483
|
+
// The pending-task tree is injected once per CONVERSATION: the main timeline
|
|
484
|
+
// and a thread each get it once, and switching between them does not make
|
|
485
|
+
// either one re-inject it.
|
|
486
|
+
require("./core/tasks").add("telegram", "dedupe-chan", "a pending fixture task");
|
|
487
|
+
const block = (threadId) => runInChat({
|
|
488
|
+
adapter: { id: "telegram", type: "telegram" }, channelId: "dedupe-chan",
|
|
489
|
+
conversationKey: conversationKeyFor("telegram", "dedupe-chan", threadId),
|
|
490
|
+
}, () => systemPrompt.buildDynamicContextBlock({
|
|
491
|
+
state: speaker,
|
|
492
|
+
runContext: {
|
|
493
|
+
provider: "claude", canonicalUserId: speaker.userId, sessionId: threadId ? "thread-session" : "main-session",
|
|
494
|
+
laneKey: conversationKeyFor("telegram", "dedupe-chan", threadId),
|
|
495
|
+
project: { name: conversationKeyFor("telegram", "dedupe-chan", threadId), dir: workspace },
|
|
496
|
+
},
|
|
497
|
+
}));
|
|
498
|
+
const full = (text) => text.includes("## Pending tasks\n");
|
|
499
|
+
assert.ok(full(block(null)), "main timeline: full tree first");
|
|
500
|
+
assert.ok(full(block(T(8))), "thread: full tree first");
|
|
501
|
+
assert.ok(!full(block(null)), "back in the main timeline: only the count (not re-injected)");
|
|
502
|
+
assert.ok(!full(block(T(8))), "back in the thread: only the count");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
(async () => {
|
|
506
|
+
const deadline = setTimeout(() => { console.error("test-thread-lanes: overall timeout"); process.exit(1); }, 170000);
|
|
507
|
+
try {
|
|
508
|
+
await wiringAndBounds();
|
|
509
|
+
await twoThreadsRunConcurrently();
|
|
510
|
+
await sameThreadQueuesThenRuns();
|
|
511
|
+
await mainLaneUnaffectedByThread();
|
|
512
|
+
await capHoldsAndFifthWaits();
|
|
513
|
+
await stopInOneThreadSparesTheOther();
|
|
514
|
+
await stopAllStopsEveryLane();
|
|
515
|
+
await wakeupFiresIntoItsThread();
|
|
516
|
+
await typingAndPreviewCarryTheirThread();
|
|
517
|
+
await compactionActsOnItsOwnLane();
|
|
518
|
+
await forkSeedsANewThreadFromMain();
|
|
519
|
+
runtimeStateTellsTheAgent();
|
|
520
|
+
console.log("thread lanes OK");
|
|
521
|
+
} finally {
|
|
522
|
+
clearTimeout(deadline);
|
|
523
|
+
try { scheduler.stopAll(); } catch (_) {}
|
|
524
|
+
}
|
|
525
|
+
process.exit(0);
|
|
526
|
+
})().catch((error) => {
|
|
527
|
+
console.error(error.stack || error);
|
|
528
|
+
console.error(JSON.stringify(events.slice(-30), null, 1));
|
|
529
|
+
process.exit(1);
|
|
530
|
+
});
|
package/core/queue-drain.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// Pure helpers for origin-aware queue draining. Kept separate from the runner
|
|
2
|
-
// so the grouping logic — which decides where a drained reply is routed under
|
|
3
|
-
// unified identity — is unit-testable without spawning a run.
|
|
4
|
-
|
|
5
|
-
// A queued message's origin channel, or a sentinel for legacy items that were
|
|
6
|
-
// queued before origin capture existed (those fall back to the finishing turn's
|
|
7
|
-
// context, exactly as before).
|
|
8
|
-
function originKey(item) {
|
|
9
|
-
return item && item.origin ? `${item.origin.transport}:${item.origin.channelId}` : "__current__";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Split the leading run of same-origin messages off the front of the queue.
|
|
13
|
-
// Returns { group, rest }: `group` is the contiguous same-origin prefix (to run
|
|
14
|
-
// now, scoped to that origin), `rest` is everything after it (to be requeued so
|
|
15
|
-
// the natural close→drain tail-recursion handles the next origin group). For a
|
|
16
|
-
// non-empty queue, `group` always has at least one item.
|
|
17
|
-
function splitLeadingOriginGroup(queue) {
|
|
18
|
-
if (!Array.isArray(queue) || queue.length === 0) return { group: [], rest: [] };
|
|
19
|
-
const firstKey = originKey(queue[0]);
|
|
20
|
-
let i = 0;
|
|
21
|
-
while (i < queue.length && originKey(queue[i]) === firstKey) i++;
|
|
22
|
-
return { group: queue.slice(0, i), rest: queue.slice(i) };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
module.exports = { originKey, splitLeadingOriginGroup };
|
package/test-queue-routing.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// Origin-aware queue draining. Under unified identity the owner's channels
|
|
2
|
-
// share one run-lock and message queue, so a message queued from one channel
|
|
3
|
-
// during a run on another must still be answered on its own channel. These
|
|
4
|
-
// tests lock in the grouping that guarantees that routing.
|
|
5
|
-
|
|
6
|
-
const assert = require("assert");
|
|
7
|
-
const { originKey, splitLeadingOriginGroup } = require("./core/queue-drain");
|
|
8
|
-
|
|
9
|
-
const tg = (id) => ({ prompt: `m${id}`, origin: { transport: "telegram", channelId: "6251055967" } });
|
|
10
|
-
const kz = (id) => ({ prompt: `m${id}`, origin: { transport: "kazee", channelId: "abc" } });
|
|
11
|
-
const legacy = (id) => ({ prompt: `m${id}` }); // queued before origin capture
|
|
12
|
-
|
|
13
|
-
// originKey
|
|
14
|
-
assert.strictEqual(originKey(tg(1)), "telegram:6251055967", "telegram key");
|
|
15
|
-
assert.strictEqual(originKey(kz(1)), "kazee:abc", "kazee key");
|
|
16
|
-
assert.strictEqual(originKey(legacy(1)), "__current__", "legacy → sentinel");
|
|
17
|
-
|
|
18
|
-
// Empty queue
|
|
19
|
-
assert.deepStrictEqual(splitLeadingOriginGroup([]), { group: [], rest: [] }, "empty");
|
|
20
|
-
|
|
21
|
-
// All one origin → single group, no remainder (the common case: preserves the
|
|
22
|
-
// prior single-batch behaviour, now scoped to that origin).
|
|
23
|
-
{
|
|
24
|
-
const q = [tg(1), tg(2), tg(3)];
|
|
25
|
-
const { group, rest } = splitLeadingOriginGroup(q);
|
|
26
|
-
assert.strictEqual(group.length, 3, "same-origin: all grouped");
|
|
27
|
-
assert.strictEqual(rest.length, 0, "same-origin: nothing requeued");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Mixed origins → only the leading same-origin run comes out; the rest is
|
|
31
|
-
// requeued so the next drain routes it to its own channel.
|
|
32
|
-
{
|
|
33
|
-
const q = [tg(1), tg(2), kz(3), tg(4)];
|
|
34
|
-
const { group, rest } = splitLeadingOriginGroup(q);
|
|
35
|
-
assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "leading telegram group");
|
|
36
|
-
assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3", "m4"], "kazee + later telegram requeued");
|
|
37
|
-
// Draining the remainder next peels off the kazee message on its own.
|
|
38
|
-
const next = splitLeadingOriginGroup(rest);
|
|
39
|
-
assert.deepStrictEqual(next.group.map((m) => m.prompt), ["m3"], "next drain: kazee alone");
|
|
40
|
-
assert.deepStrictEqual(next.rest.map((m) => m.prompt), ["m4"], "next drain: trailing telegram requeued");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Legacy items (no origin) group together under the sentinel and fall back to
|
|
44
|
-
// the finishing turn's context — matching pre-change behaviour.
|
|
45
|
-
{
|
|
46
|
-
const q = [legacy(1), legacy(2), tg(3)];
|
|
47
|
-
const { group, rest } = splitLeadingOriginGroup(q);
|
|
48
|
-
assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "legacy grouped");
|
|
49
|
-
assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3"], "origin item requeued");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
console.log("queue-routing OK");
|