deepdebug-local-agent 1.12.0 → 1.13.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/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/git-exec.js +5 -4
- package/src/la-protocol-v2/jobs/manager.js +4 -2
- package/src/la-protocol-v2/jobs/workspace-materializer.js +35 -12
- package/src/la-protocol-v2/runtime.js +32 -3
- package/src/workspace-manager.js +3 -2
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/traces.test.js +3 -3
- package/test/la-protocol-v2/workspace-materializer-hostile.test.js +105 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// LA-1 + LA-3 (ADR-0014 Design A, contract 2.3): chat on the Local Agent. The REAL v2 link (buildV2Link with every flag), the REAL agent2 runner, ChatStore and content
|
|
2
|
+
// handlers, against the ReferenceCp (which validates every frame against the contract) and a FAKE agent2 (helpers/fake-agent2.js: `kind=chat`, `/health` kinds,
|
|
3
|
+
// the content stream). The decoded bodies are validated here against job.stream.body / chat.session.body. What it proves is limited by the fakes: the jar of agent2 is
|
|
4
|
+
// exercised by chat-real-e2e.test.js when AGENT2_JAR points at one (opt-in).
|
|
5
|
+
import { test, after } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { closeAllFakes } from "./helpers/fake-llm.js";
|
|
11
|
+
import { SCENARIOS } from "./helpers/fake-agent2.js";
|
|
12
|
+
import { agent2Rig, CANARIES, GH, waitFor, uuid } from "./helpers/agent2-rig.js";
|
|
13
|
+
import { loadContract } from "../../src/la-protocol-v2/contract.js";
|
|
14
|
+
import { agent2ChildEnv } from "../../src/la-protocol-v2/jobs/agent2/supervisor.js";
|
|
15
|
+
import { resolveAgent2RunnerConfig } from "../../src/la-protocol-v2/jobs/agent2/config.js";
|
|
16
|
+
|
|
17
|
+
after(closeAllFakes);
|
|
18
|
+
const contract = loadContract();
|
|
19
|
+
const PROMPT = "CHAT-PROMPT-CANARY where is the login implemented?";
|
|
20
|
+
const CHAT_CANARIES = ["CHAT-PROMPT-CANARY", "CHAT-TOKEN-ONE", "CHAT-TOKEN-TWO", "CHAT-ANSWER-FINAL", "CHAT-FILES", "CHAT-SLOW", "CHAT-SECRET", "CHAT-FAIL"];
|
|
21
|
+
const PEM_MARK = "BEGIN RSA PRIVATE KEY";
|
|
22
|
+
|
|
23
|
+
const KINDS_CHAT = ["fix", "implement", "chat"];
|
|
24
|
+
|
|
25
|
+
async function chatRig({ scenario = "chat_answer", env = {}, fakeKinds = KINDS_CHAT, chatDir = null, ...rest } = {}) {
|
|
26
|
+
const dir = chatDir || fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-it-"));
|
|
27
|
+
const r = await agent2Rig({ scenario, fakeKinds, env: { LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CHAT_DIR: dir, LOCAL_AGENT_CHAT_POLL_MS: "400", LOCAL_AGENT_CHAT_PROBE_MS: "10000", LOCAL_AGENT_CHAT_STREAM_DRAIN_MS: "3000", ...env }, ...rest });
|
|
28
|
+
const stop = r.stop;
|
|
29
|
+
r.chatDir = dir;
|
|
30
|
+
r.stop = async () => { await stop(); if (!chatDir) fs.rmSync(dir, { recursive: true, force: true }); };
|
|
31
|
+
return r;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const chatAssign = (r, over = {}) => r.assign({ kind: "chat", incidentRef: undefined, inputs: [{ inline: PROMPT, transient: true }], ...over });
|
|
35
|
+
|
|
36
|
+
/** content.request -> { status, errorCode, body (parsed JSON or null), text, frames } */
|
|
37
|
+
async function content(r, { kind, ref, workspaceId = "ws-1", waitMs = 6000 }) {
|
|
38
|
+
const requestId = uuid();
|
|
39
|
+
r.cp.sendData("content.request", { kind, ref, actor: { userId: "u-1", role: "member" } }, { workspaceId, requestId });
|
|
40
|
+
await waitFor(() => r.cp.received.some((f) => f.type === "content.response" && f.requestId === requestId && (f.chunk?.last || f.payload.status !== "ok")), waitMs);
|
|
41
|
+
const frames = r.cp.received.filter((f) => f.type === "content.response" && f.requestId === requestId);
|
|
42
|
+
const text = Buffer.concat(r.cp.contentRequests.get(requestId)?.chunks || []).toString("utf8");
|
|
43
|
+
let body = null;
|
|
44
|
+
if (frames[0]?.payload.status === "ok") body = JSON.parse(text);
|
|
45
|
+
return { requestId, status: frames[0]?.payload.status, errorCode: frames[0]?.payload.errorCode, body, text, frames };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Follows job.stream from the job id to done:true. Every page is checked against the contract's body schema. */
|
|
49
|
+
async function drain(r, jobId, { workspaceId = "ws-1", max = 60 } = {}) {
|
|
50
|
+
const events = [];
|
|
51
|
+
const pages = [];
|
|
52
|
+
let ref = jobId;
|
|
53
|
+
for (let n = 0; n < max; n++) {
|
|
54
|
+
const a = await content(r, { kind: "job.stream", ref, workspaceId });
|
|
55
|
+
assert.equal(a.status, "ok", `page ${n}: ${a.status} ${a.errorCode}`);
|
|
56
|
+
assert.equal(contract.validateBody("job.stream.body.schema.json", a.body), true, `page ${n} is not a valid job.stream body: ${a.text.slice(0, 200)}`);
|
|
57
|
+
pages.push(a.body);
|
|
58
|
+
events.push(...a.body.events);
|
|
59
|
+
ref = a.body.nextRef;
|
|
60
|
+
if (a.body.done) return { events, pages };
|
|
61
|
+
}
|
|
62
|
+
throw new Error("the stream did not end");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const wireWithoutContent = (r) => JSON.stringify(r.cp.received.filter((f) => f.type !== "content.response"));
|
|
66
|
+
const noCanary = (text, list = CHAT_CANARIES) => { for (const c of list) assert.ok(!text.includes(c), `content leaked outside the relay: ${c}`); };
|
|
67
|
+
|
|
68
|
+
test("announces job.chat and job.inline.16k only from a complete chat: flag, agent2 that lists chat, content handlers", async () => {
|
|
69
|
+
const on = await chatRig();
|
|
70
|
+
try {
|
|
71
|
+
for (const frame of [on.cp.registers[0], on.cp.hellos[0]]) {
|
|
72
|
+
assert.ok(frame.capabilities.includes("job.chat") && frame.capabilities.includes("job.inline.16k"), JSON.stringify(frame.capabilities));
|
|
73
|
+
assert.ok(frame.capabilities.includes("job.assign"), "job.chat implies job.assign");
|
|
74
|
+
}
|
|
75
|
+
assert.deepEqual(on.link.agent2.kinds, KINDS_CHAT);
|
|
76
|
+
} finally { await on.stop(); }
|
|
77
|
+
|
|
78
|
+
// agent2 that does not list chat (an old build, or a sidecar started without AGENT2_LOCAL_CHAT_ENABLED): not announced, and a chat job is refused, never run as another kind
|
|
79
|
+
const old = await chatRig({ fakeKinds: ["fix", "implement"] });
|
|
80
|
+
try {
|
|
81
|
+
assert.ok(!old.cp.registers[0].capabilities.includes("job.chat"));
|
|
82
|
+
assert.ok(!old.cp.registers[0].capabilities.includes("job.inline.16k"));
|
|
83
|
+
assert.ok(old.cp.registers[0].capabilities.includes("job.assign"), "fix/implement are unchanged");
|
|
84
|
+
const a = chatAssign(old);
|
|
85
|
+
assert.deepEqual([(await a.acked()).status, (await a.acked()).errorCode], ["rejected", "protocol_unsupported"]);
|
|
86
|
+
assert.equal(old.fake.jobs.size, 0, "nothing reached agent2");
|
|
87
|
+
} finally { await old.stop(); }
|
|
88
|
+
|
|
89
|
+
// flag off: the same agent2 and nothing is announced
|
|
90
|
+
const off = await agent2Rig({ scenario: "chat_answer", fakeKinds: KINDS_CHAT });
|
|
91
|
+
try {
|
|
92
|
+
assert.ok(!off.cp.registers[0].capabilities.includes("job.chat"));
|
|
93
|
+
assert.equal(off.link.chat, undefined);
|
|
94
|
+
const a = off.assign({ kind: "chat", incidentRef: undefined, inputs: [{ inline: PROMPT, transient: true }] });
|
|
95
|
+
assert.equal((await a.acked()).errorCode, "protocol_unsupported");
|
|
96
|
+
const q = await content(off, { kind: "job.stream", ref: a.p.jobId });
|
|
97
|
+
assert.deepEqual([q.status, q.errorCode], ["error", "protocol_unsupported"], "without the chat, job.stream is not served");
|
|
98
|
+
const s = await content(off, { kind: "chat.session", ref: a.p.jobId });
|
|
99
|
+
assert.deepEqual([s.status, s.errorCode], ["error", "protocol_unsupported"]);
|
|
100
|
+
} finally { await off.stop(); }
|
|
101
|
+
|
|
102
|
+
// chat requested but the content handlers are off: it says why and stays off (a chat nobody can read is not announced)
|
|
103
|
+
const noContent = await agent2Rig({ scenario: "chat_answer", fakeKinds: KINDS_CHAT, env: { LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CONTENT_HANDLERS: "false", LOCAL_AGENT_CHAT_DIR: path.join(os.tmpdir(), "la-chat-never") } });
|
|
104
|
+
try {
|
|
105
|
+
assert.ok(!noContent.cp.registers[0].capabilities.includes("job.chat"));
|
|
106
|
+
assert.ok(noContent.errors.some((m) => /LOCAL_AGENT_CHAT=true but the content handlers/.test(m)), noContent.errors.join("|"));
|
|
107
|
+
assert.ok(!fs.existsSync(path.join(os.tmpdir(), "la-chat-never")), "no folder is created for a chat that stays off");
|
|
108
|
+
} finally { await noContent.stop(); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the chat end to end: job.assign kind chat -> agent2 gets kind chat and the prompt -> job.stream pages (cursor) -> chat.session; the job frames carry no content", async () => {
|
|
112
|
+
const r = await chatRig();
|
|
113
|
+
try {
|
|
114
|
+
const a = chatAssign(r);
|
|
115
|
+
assert.equal((await a.acked()).status, "accepted");
|
|
116
|
+
await r.done(a.p.jobId, 8000);
|
|
117
|
+
const job = r.fake.jobs.get(a.p.jobId);
|
|
118
|
+
assert.equal(job.kind, "chat", "agent2 was asked for kind=chat (its unified loop), not implement");
|
|
119
|
+
assert.equal(job.request.text, PROMPT);
|
|
120
|
+
assert.equal(job.workspaceId.length, 36);
|
|
121
|
+
const res = r.frames(a.p.jobId, "job.result")[0].payload;
|
|
122
|
+
assert.equal(res.outcome, "no_change");
|
|
123
|
+
assert.equal(res.prUrl, undefined, "a chat opens no pull request");
|
|
124
|
+
assert.equal(res.filesChangedCount, 0, "answered, nothing changed on disk");
|
|
125
|
+
assert.equal(r.frames(a.p.jobId, "job.event").at(-1).payload.state, "succeeded");
|
|
126
|
+
|
|
127
|
+
// job.stream from the job id: every event line as agent2 emitted it, in order, with its position
|
|
128
|
+
const { events, pages } = await drain(r, a.p.jobId);
|
|
129
|
+
assert.deepEqual(events.map((e) => e.i), [0, 1, 2, 3, 4]);
|
|
130
|
+
assert.deepEqual(events.map((e) => JSON.parse(e.e).type), ["session_start", "status", "token", "token", "summary"]);
|
|
131
|
+
assert.match(events[2].e, /CHAT-TOKEN-ONE/);
|
|
132
|
+
assert.equal(pages.at(-1).done, true);
|
|
133
|
+
for (const p of pages) assert.match(p.nextRef, /^[A-Za-z0-9_-]{43}$/);
|
|
134
|
+
|
|
135
|
+
// the same cursor twice = the same page (a lost answer is asked for again); a cursor is not the job id
|
|
136
|
+
const first = await content(r, { kind: "job.stream", ref: a.p.jobId });
|
|
137
|
+
const next = first.body.nextRef;
|
|
138
|
+
const x = await content(r, { kind: "job.stream", ref: next });
|
|
139
|
+
const y = await content(r, { kind: "job.stream", ref: next });
|
|
140
|
+
assert.deepEqual(x.body, y.body);
|
|
141
|
+
|
|
142
|
+
// chat.session: the turns, in order, role/ts/text
|
|
143
|
+
const s = await content(r, { kind: "chat.session", ref: a.p.jobId });
|
|
144
|
+
assert.equal(s.status, "ok");
|
|
145
|
+
assert.equal(contract.validateBody("chat.session.body.schema.json", s.body), true, s.text);
|
|
146
|
+
assert.deepEqual(s.body.turns.map((t) => t.role), ["user", "assistant"]);
|
|
147
|
+
assert.equal(s.body.turns[0].text, PROMPT);
|
|
148
|
+
assert.match(s.body.turns[1].text, /CHAT-ANSWER-FINAL/);
|
|
149
|
+
assert.equal(s.body.truncated, false);
|
|
150
|
+
for (const t of s.body.turns) assert.match(t.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/);
|
|
151
|
+
|
|
152
|
+
// the content never rides a job frame, an audit or a log: only the content.response frames carry it
|
|
153
|
+
noCanary(wireWithoutContent(r));
|
|
154
|
+
noCanary(r.everything().replace(/"type":"content\.response"/g, ""), ["CHAT-PROMPT-CANARY", "CHAT-TOKEN-ONE", "CHAT-ANSWER-FINAL"]);
|
|
155
|
+
assert.deepEqual(r.cp.errorsSent, [], "every frame was valid for the contract validator of the CP");
|
|
156
|
+
noCanary([...r.logs, ...r.errors].join("\n"));
|
|
157
|
+
noCanary(JSON.stringify([r.link.chat.audit.tail(500), r.link.chat.store.stats, r.link.agent2.stats, r.link.jobs.audit.tail(500)]));
|
|
158
|
+
} finally { await r.stop(); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a 16384-char prompt (job.inline.16k) reaches agent2 whole; a prompt over 16 KiB of UTF-8 is never cut: the job fails and agent2 is not called", async () => {
|
|
162
|
+
const r = await chatRig({ env: { LOCAL_AGENT_JOBS_MEM_PER_JOB_BYTES: String(1024 * 1024) } });
|
|
163
|
+
try {
|
|
164
|
+
const big = `CHAT-PROMPT-CANARY ${"a".repeat(16384 - 19)}`;
|
|
165
|
+
assert.equal(big.length, 16384);
|
|
166
|
+
const a = chatAssign(r, { inputs: [{ inline: big, transient: true }] });
|
|
167
|
+
assert.equal((await a.acked()).status, "accepted", "the frame with a full 16384-char inline fits the 48 KiB limit");
|
|
168
|
+
await r.done(a.p.jobId, 8000);
|
|
169
|
+
assert.equal(r.fake.jobs.get(a.p.jobId).request.text.length, 16384);
|
|
170
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "no_change");
|
|
171
|
+
const s = await content(r, { kind: "chat.session", ref: a.p.jobId });
|
|
172
|
+
assert.equal(s.body.turns[0].text.length, 16384);
|
|
173
|
+
|
|
174
|
+
// 5000 four-byte characters: 5000 chars (the schema allows it) but 20000 bytes (agent2 and the contract's frame budget count bytes)
|
|
175
|
+
const wide = "\u{1F600}".repeat(5000);
|
|
176
|
+
const posts = () => r.fake.requests.filter((x) => x.method === "POST" && x.path === "/local/v1/jobs").length;
|
|
177
|
+
const before = posts();
|
|
178
|
+
const b = chatAssign(r, { inputs: [{ inline: wide, transient: true }] });
|
|
179
|
+
assert.equal((await b.acked()).status, "accepted");
|
|
180
|
+
await r.done(b.p.jobId, 8000);
|
|
181
|
+
const res = r.frames(b.p.jobId, "job.result")[0].payload;
|
|
182
|
+
assert.equal(res.outcome, "failed");
|
|
183
|
+
assert.equal(r.fake.jobs.has(b.p.jobId), false, "agent2 never saw a truncated prompt");
|
|
184
|
+
assert.equal(posts(), before, "agent2 was not even called: the Local Agent refuses the prompt itself");
|
|
185
|
+
assert.equal(r.frames(b.p.jobId, "job.event").at(-1).payload.state, "failed");
|
|
186
|
+
const st = await content(r, { kind: "job.stream", ref: b.p.jobId });
|
|
187
|
+
assert.equal(st.body.done, true, "a failed chat closes its stream");
|
|
188
|
+
} finally { await r.stop(); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("long-poll: held up to the hold while nothing is new, answered as soon as an event arrives; a done:false empty page keeps the same cursor", async () => {
|
|
192
|
+
const r = await chatRig({ scenario: "chat_slow", env: { LOCAL_AGENT_CHAT_POLL_MS: "500" } });
|
|
193
|
+
try {
|
|
194
|
+
const a = chatAssign(r);
|
|
195
|
+
await a.acked();
|
|
196
|
+
await waitFor(() => r.fake.jobs.get(a.p.jobId)?.lines.length >= 1, 5000);
|
|
197
|
+
// drain what is there, then ask again: the live job ticks every 60 ms so there is always something soon
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
const p1 = await content(r, { kind: "job.stream", ref: a.p.jobId });
|
|
200
|
+
assert.equal(p1.status, "ok");
|
|
201
|
+
assert.ok(p1.body.events.length >= 1);
|
|
202
|
+
assert.equal(p1.body.done, false);
|
|
203
|
+
const p2 = await content(r, { kind: "job.stream", ref: p1.body.nextRef });
|
|
204
|
+
assert.ok(p2.body.events[0].i === p1.body.events.at(-1).i + 1, "continues exactly after the last event of the previous page");
|
|
205
|
+
assert.ok(Date.now() - t0 < 3000);
|
|
206
|
+
// stop the ticks: cancel; then a poll on the end gets done:true
|
|
207
|
+
await r.cancel(a.p.jobId);
|
|
208
|
+
await r.done(a.p.jobId, 8000);
|
|
209
|
+
const end = await drain(r, a.p.jobId);
|
|
210
|
+
assert.equal(end.pages.at(-1).done, true);
|
|
211
|
+
} finally { await r.stop(); }
|
|
212
|
+
|
|
213
|
+
// idle: an empty page after the hold, same cursor, done=false
|
|
214
|
+
const idle = await chatRig({ scenario: "stuck", env: { LOCAL_AGENT_CHAT_POLL_MS: "250" } });
|
|
215
|
+
try {
|
|
216
|
+
const a = chatAssign(idle);
|
|
217
|
+
await a.acked();
|
|
218
|
+
await waitFor(() => idle.fake.jobs.has(a.p.jobId), 5000);
|
|
219
|
+
const t0 = Date.now();
|
|
220
|
+
const p = await content(idle, { kind: "job.stream", ref: a.p.jobId });
|
|
221
|
+
assert.ok(Date.now() - t0 >= 200, `held ${Date.now() - t0} ms`);
|
|
222
|
+
assert.deepEqual([p.body.events.length, p.body.done], [0, false]);
|
|
223
|
+
const q = await content(idle, { kind: "job.stream", ref: p.body.nextRef });
|
|
224
|
+
assert.equal(q.body.nextRef, p.body.nextRef, "the same position, the same cursor");
|
|
225
|
+
await idle.cancel(a.p.jobId);
|
|
226
|
+
} finally { await idle.stop(); }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("job.stream asked while the job is still QUEUED (no ChatStore record yet) waits for the job instead of failing; once the job starts the request is answered", async () => {
|
|
230
|
+
const scenario = (job, h) => (job.request.text.includes("FIRST-JOB") ? SCENARIOS.chat_slow(job, h) : SCENARIOS.chat_answer(job, h));
|
|
231
|
+
const r = await chatRig({ scenario, env: { LOCAL_AGENT_CHAT_POLL_MS: "4000" } });
|
|
232
|
+
try {
|
|
233
|
+
const first = chatAssign(r, { inputs: [{ inline: "FIRST-JOB hold the runner", transient: true }] });
|
|
234
|
+
await first.acked();
|
|
235
|
+
await waitFor(() => r.fake.jobs.has(first.p.jobId), 5000);
|
|
236
|
+
const second = chatAssign(r); // one job at a time: this one is queued in the manager and never reached its runner
|
|
237
|
+
assert.equal((await second.acked()).status, "accepted");
|
|
238
|
+
assert.equal(r.fake.jobs.has(second.p.jobId), false);
|
|
239
|
+
assert.equal(r.link.jobs.isActiveChat(second.p.jobId), true);
|
|
240
|
+
const requestId = uuid();
|
|
241
|
+
r.cp.sendData("content.request", { kind: "job.stream", ref: second.p.jobId, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
|
|
242
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
243
|
+
assert.equal(r.cp.received.some((f) => f.type === "content.response" && f.requestId === requestId), false, "held, not refused");
|
|
244
|
+
await r.cancel(first.p.jobId); // the runner is free: the queued job starts, the held request is answered
|
|
245
|
+
await waitFor(() => r.cp.received.some((f) => f.type === "content.response" && f.requestId === requestId), 8000);
|
|
246
|
+
const frames = r.cp.received.filter((f) => f.type === "content.response" && f.requestId === requestId);
|
|
247
|
+
assert.equal(frames[0].payload.status, "ok");
|
|
248
|
+
await r.done(second.p.jobId, 8000);
|
|
249
|
+
const { events } = await drain(r, second.p.jobId);
|
|
250
|
+
assert.equal(events.length, 5);
|
|
251
|
+
// a job nobody holds is not_found at once (no wait)
|
|
252
|
+
const t0 = Date.now();
|
|
253
|
+
const nf = await content(r, { kind: "job.stream", ref: uuid() });
|
|
254
|
+
assert.equal(nf.status, "not_found");
|
|
255
|
+
assert.ok(Date.now() - t0 < 2000);
|
|
256
|
+
} finally { await r.stop(); }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("cancel: job.cancel reaches agent2, the job ends cancelled, what was written stays, the stream closes with done:true and the session keeps the user's turn", async () => {
|
|
260
|
+
const r = await chatRig({ scenario: "chat_slow" });
|
|
261
|
+
try {
|
|
262
|
+
const a = chatAssign(r);
|
|
263
|
+
await a.acked();
|
|
264
|
+
await waitFor(() => r.fake.jobs.get(a.p.jobId)?.lines.length >= 3, 5000);
|
|
265
|
+
assert.equal((await r.cancel(a.p.jobId)).status, "accepted");
|
|
266
|
+
await r.done(a.p.jobId, 10000);
|
|
267
|
+
assert.equal(r.fake.jobs.get(a.p.jobId).record.cancelCalls, 1);
|
|
268
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "cancelled");
|
|
269
|
+
const { events, pages } = await drain(r, a.p.jobId);
|
|
270
|
+
assert.equal(pages.at(-1).done, true);
|
|
271
|
+
assert.ok(events.length >= 3, "the part already written is still there");
|
|
272
|
+
assert.match(events[0].e, /CHAT-SLOW first/);
|
|
273
|
+
const s = await content(r, { kind: "chat.session", ref: a.p.jobId });
|
|
274
|
+
assert.equal(s.body.turns[0].role, "user");
|
|
275
|
+
// the file on disk says it ended cancelled
|
|
276
|
+
const file = path.join(r.chatDir, r.cp.tenantId, `${a.p.jobId}.jsonl`);
|
|
277
|
+
const recs = fs.readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
|
278
|
+
assert.equal(recs.at(-1).k, "end");
|
|
279
|
+
assert.equal(recs.at(-1).state, "cancelled");
|
|
280
|
+
assert.equal(r.link.agent2.active, null, "no orphan job in the runner");
|
|
281
|
+
noCanary(wireWithoutContent(r));
|
|
282
|
+
} finally { await r.stop(); }
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("outcome of a chat: the loop changed files but the model concluded no_change -> outcome stays no_change and filesChangedCount says how many; failed stays failed", async () => {
|
|
286
|
+
const r = await chatRig({ scenario: "chat_files" });
|
|
287
|
+
try {
|
|
288
|
+
const a = chatAssign(r);
|
|
289
|
+
await a.acked();
|
|
290
|
+
await r.done(a.p.jobId, 8000);
|
|
291
|
+
const res = r.frames(a.p.jobId, "job.result")[0].payload;
|
|
292
|
+
assert.deepEqual([res.outcome, res.filesChangedCount, res.prUrl], ["no_change", 3, undefined]);
|
|
293
|
+
assert.equal(r.frames(a.p.jobId, "job.event").at(-1).payload.filesChanged, 3);
|
|
294
|
+
assert.deepEqual(r.cp.errorsSent, []);
|
|
295
|
+
noCanary(wireWithoutContent(r));
|
|
296
|
+
} finally { await r.stop(); }
|
|
297
|
+
|
|
298
|
+
const f = await chatRig({ scenario: "chat_fail" });
|
|
299
|
+
try {
|
|
300
|
+
const a = chatAssign(f);
|
|
301
|
+
await a.acked();
|
|
302
|
+
await f.done(a.p.jobId, 8000);
|
|
303
|
+
const res = f.frames(a.p.jobId, "job.result")[0].payload;
|
|
304
|
+
assert.equal(res.outcome, "failed");
|
|
305
|
+
assert.equal(f.frames(a.p.jobId, "job.event").at(-1).payload.errorCode, "llm_error");
|
|
306
|
+
const s = await content(f, { kind: "chat.session", ref: a.p.jobId });
|
|
307
|
+
assert.deepEqual(s.body.turns.map((t) => t.role), ["user", "assistant"], "what the person saw before it failed is kept");
|
|
308
|
+
assert.match(s.body.turns[1].text, /CHAT-FAIL started/);
|
|
309
|
+
assert.equal((await drain(f, a.p.jobId)).pages.at(-1).done, true);
|
|
310
|
+
} finally { await f.stop(); }
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("DLP: secrets in the prompt, the stream and the answer are masked on the disk and on the wire; a private key is withheld; DLP not enforce at request time = nothing served", async () => {
|
|
314
|
+
const r = await chatRig({ scenario: "chat_secret" });
|
|
315
|
+
try {
|
|
316
|
+
const a = chatAssign(r, { inputs: [{ inline: `CHAT-PROMPT-CANARY my token is ${GH}`, transient: true }] });
|
|
317
|
+
await a.acked();
|
|
318
|
+
await r.done(a.p.jobId, 8000);
|
|
319
|
+
const { events } = await drain(r, a.p.jobId);
|
|
320
|
+
const s = await content(r, { kind: "chat.session", ref: a.p.jobId });
|
|
321
|
+
const everything = JSON.stringify([events, s.body]);
|
|
322
|
+
assert.ok(!everything.includes(GH), "the token never leaves");
|
|
323
|
+
assert.ok(!everything.includes(PEM_MARK) && !everything.includes("MIIEowIBAAKC"), "the private key never leaves");
|
|
324
|
+
assert.match(everything, /CHAT-SECRET-CANARY/, "the rest of the content is delivered");
|
|
325
|
+
assert.match(everything, /withheld by the Local Agent DLP/);
|
|
326
|
+
assert.match(everything, /REDACTED/);
|
|
327
|
+
const onDisk = fs.readFileSync(path.join(r.chatDir, r.cp.tenantId, `${a.p.jobId}.jsonl`), "utf8");
|
|
328
|
+
assert.ok(!onDisk.includes(GH) && !onDisk.includes("MIIEowIBAAKC") && !onDisk.includes(PEM_MARK), "no secret on the customer's disk either");
|
|
329
|
+
// the agent2 request itself carries the prompt as the person wrote it: that is agent2's local input, not the store
|
|
330
|
+
// the door closes per request: DLP is read at every request
|
|
331
|
+
r.env.LOCAL_AGENT_DLP_MODE = "warn";
|
|
332
|
+
const w = await content(r, { kind: "job.stream", ref: a.p.jobId });
|
|
333
|
+
assert.deepEqual([w.status, w.errorCode], ["error", "protocol_unsupported"]);
|
|
334
|
+
const w2 = await content(r, { kind: "chat.session", ref: a.p.jobId });
|
|
335
|
+
assert.deepEqual([w2.status, w2.errorCode], ["error", "protocol_unsupported"]);
|
|
336
|
+
r.env.LOCAL_AGENT_DLP_MODE = "enforce";
|
|
337
|
+
assert.equal((await content(r, { kind: "chat.session", ref: a.p.jobId })).status, "ok");
|
|
338
|
+
noCanary(wireWithoutContent(r));
|
|
339
|
+
} finally { await r.stop(); }
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("egress masks again: a conversation file written by something else (or by an older build) with a secret in it is still served masked", async () => {
|
|
343
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-old-"));
|
|
344
|
+
const r = await chatRig({ chatDir: dir });
|
|
345
|
+
try {
|
|
346
|
+
const id = uuid();
|
|
347
|
+
const tenantDir = path.join(dir, r.cp.tenantId);
|
|
348
|
+
const rec = (o) => `${JSON.stringify(o)}\n`;
|
|
349
|
+
fs.writeFileSync(path.join(tenantDir, `${id}.jsonl`), rec({ k: "meta", v: 1, ws: "ws-1", ts: "2026-11-05T10:00:00.000Z" })
|
|
350
|
+
+ rec({ k: "turn", role: "user", ts: "2026-11-05T10:00:00.000Z", text: `hello ${GH}` })
|
|
351
|
+
+ rec({ k: "ev", e: JSON.stringify({ type: "token", content: `secret ${GH}` }) })
|
|
352
|
+
+ rec({ k: "turn", role: "assistant", ts: "2026-11-05T10:00:01.000Z", text: `answer ${GH}` })
|
|
353
|
+
+ rec({ k: "end", state: "done", ts: "2026-11-05T10:00:01.000Z" }), { mode: 0o600 });
|
|
354
|
+
const { events } = await drain(r, id);
|
|
355
|
+
const s = await content(r, { kind: "chat.session", ref: id });
|
|
356
|
+
const all = JSON.stringify([events, s.body]);
|
|
357
|
+
assert.ok(!all.includes(GH), "the second mask, on the way out, caught it");
|
|
358
|
+
assert.match(all, /REDACTED/);
|
|
359
|
+
assert.equal(events.length, 1);
|
|
360
|
+
JSON.parse(events[0].e); // masking a piece never breaks the JSON of an event line
|
|
361
|
+
} finally { await r.stop(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("egress, quoted secrets: a stored event line with {\"db_password\":...} / password = \"...\" in token, tool result and summary is masked in the job.stream BODY (decoded strings, not the JSON text)", async () => {
|
|
365
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-q-"));
|
|
366
|
+
const r = await chatRig({ chatDir: dir });
|
|
367
|
+
try {
|
|
368
|
+
const PW = "Sup3rS3cretValue!99";
|
|
369
|
+
const KEY = "abcd1234efgh5678ijkl9999";
|
|
370
|
+
const id = uuid();
|
|
371
|
+
const rec = (o) => `${JSON.stringify(o)}\n`;
|
|
372
|
+
const evs = [
|
|
373
|
+
{ type: "token", content: `password = "${PW}"` },
|
|
374
|
+
{ type: "tool_result", content: { db_password: PW, api_key: KEY } },
|
|
375
|
+
{ type: "summary", content: { answer: `password = "${PW}" and api_key: "${KEY}"`, conversationId: id } },
|
|
376
|
+
];
|
|
377
|
+
fs.writeFileSync(path.join(dir, r.cp.tenantId, `${id}.jsonl`), rec({ k: "meta", v: 1, ws: "ws-1", ts: "2026-11-05T10:00:00.000Z" })
|
|
378
|
+
+ evs.map((e) => rec({ k: "ev", e: JSON.stringify(e) })).join("") + rec({ k: "end", state: "done", ts: "2026-11-05T10:00:01.000Z" }), { mode: 0o600 });
|
|
379
|
+
const { events } = await drain(r, id);
|
|
380
|
+
assert.equal(events.length, 3);
|
|
381
|
+
const wire = JSON.stringify(events);
|
|
382
|
+
assert.ok(!wire.includes(PW) && !wire.includes(KEY), "no quoted secret in the body that crosses the control plane");
|
|
383
|
+
for (const e of events) JSON.parse(e.e);
|
|
384
|
+
assert.match(wire, /REDACTED/);
|
|
385
|
+
} finally { await r.stop(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("refs: a cursor of another workspace, a foreign workspace, a path-like or unknown ref are all not_found, with no oracle", async () => {
|
|
389
|
+
const r = await chatRig();
|
|
390
|
+
try {
|
|
391
|
+
const a = chatAssign(r);
|
|
392
|
+
await a.acked();
|
|
393
|
+
await r.done(a.p.jobId, 8000);
|
|
394
|
+
const ok = await content(r, { kind: "job.stream", ref: a.p.jobId });
|
|
395
|
+
const cursor = ok.body.nextRef;
|
|
396
|
+
const nf = async (o) => { const x = await content(r, o); assert.deepEqual([x.status, x.errorCode ?? null], ["not_found", null], JSON.stringify(o)); };
|
|
397
|
+
await nf({ kind: "job.stream", ref: a.p.jobId, workspaceId: "ws-other" });
|
|
398
|
+
await nf({ kind: "job.stream", ref: cursor, workspaceId: "ws-other" });
|
|
399
|
+
await nf({ kind: "job.stream", ref: uuid() });
|
|
400
|
+
await nf({ kind: "job.stream", ref: "A".repeat(43) });
|
|
401
|
+
await nf({ kind: "job.stream", ref: "../../etc/passwd" });
|
|
402
|
+
await nf({ kind: "job.stream", ref: `${a.p.jobId}/../x` });
|
|
403
|
+
await nf({ kind: "chat.session", ref: uuid() });
|
|
404
|
+
await nf({ kind: "chat.session", ref: cursor });
|
|
405
|
+
await nf({ kind: "chat.session", ref: "../../../etc/passwd" });
|
|
406
|
+
await nf({ kind: "chat.session", ref: a.p.jobId, workspaceId: "ws-other" });
|
|
407
|
+
// a byte range makes no sense on a page
|
|
408
|
+
r.cp.sendData("content.request", { kind: "job.stream", ref: a.p.jobId, range: { offset: 0, length: 10 }, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId: uuid() });
|
|
409
|
+
assert.deepEqual(r.cp.errorsSent, []);
|
|
410
|
+
} finally { await r.stop(); }
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("after a restart of the Local Agent: the conversation is read from the disk (retention), a new link serves chat.session and job.stream of the old job", async () => {
|
|
414
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-restart-"));
|
|
415
|
+
const a = await chatRig({ chatDir: dir });
|
|
416
|
+
let jobId;
|
|
417
|
+
try {
|
|
418
|
+
const j = chatAssign(a);
|
|
419
|
+
await j.acked();
|
|
420
|
+
await a.done(j.p.jobId, 8000);
|
|
421
|
+
jobId = j.p.jobId;
|
|
422
|
+
await a.link.chat.store.flush();
|
|
423
|
+
} finally { await a.stop(); }
|
|
424
|
+
const b = await chatRig({ chatDir: dir });
|
|
425
|
+
try {
|
|
426
|
+
const s = await content(b, { kind: "chat.session", ref: jobId });
|
|
427
|
+
assert.equal(s.status, "ok");
|
|
428
|
+
assert.deepEqual(s.body.turns.map((t) => t.role), ["user", "assistant"]);
|
|
429
|
+
const { events, pages } = await drain(b, jobId);
|
|
430
|
+
assert.equal(events.length, 5);
|
|
431
|
+
assert.equal(pages.at(-1).done, true);
|
|
432
|
+
} finally { await b.stop(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("a many-line chat: pages are bounded (events and bytes), every page fits the contract and the lines come back whole and in order", async () => {
|
|
436
|
+
const r = await chatRig({ scenario: "chat_many", env: { LOCAL_AGENT_CHAT_PAGE_EVENTS: "10", LOCAL_AGENT_CHAT_PAGE_BYTES: String(40 * 1024) } });
|
|
437
|
+
try {
|
|
438
|
+
const a = chatAssign(r);
|
|
439
|
+
await a.acked();
|
|
440
|
+
await r.done(a.p.jobId, 10000);
|
|
441
|
+
const { events, pages } = await drain(r, a.p.jobId);
|
|
442
|
+
assert.equal(events.length, 61);
|
|
443
|
+
assert.deepEqual(events.map((e) => e.i), [...Array(61).keys()]);
|
|
444
|
+
assert.ok(pages.every((p) => p.events.length <= 10));
|
|
445
|
+
assert.ok(pages.length >= 7);
|
|
446
|
+
for (const e of events) JSON.parse(e.e); // never a cut line
|
|
447
|
+
} finally { await r.stop(); }
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("agent2 that lost chat after the start (restarted without the flag): the job fails (infra), agent2 gets no job, and it is never run as another kind", async () => {
|
|
451
|
+
const kinds = [...KINDS_CHAT];
|
|
452
|
+
const r = await chatRig({ fakeKinds: kinds });
|
|
453
|
+
try {
|
|
454
|
+
assert.ok(r.cp.registers[0].capabilities.includes("job.chat"));
|
|
455
|
+
kinds.splice(kinds.indexOf("chat"), 1); // /health no longer lists chat
|
|
456
|
+
const a = chatAssign(r);
|
|
457
|
+
await a.acked();
|
|
458
|
+
await r.done(a.p.jobId, 8000);
|
|
459
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "failed");
|
|
460
|
+
assert.equal(r.frames(a.p.jobId, "job.event").at(-1).payload.errorCode, "infra");
|
|
461
|
+
assert.equal(r.fake.jobs.size, 0);
|
|
462
|
+
assert.equal((await drain(r, a.p.jobId)).pages.at(-1).done, true, "the stream of a failed chat is closed");
|
|
463
|
+
} finally { await r.stop(); }
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("kind chat does not weaken the other kinds: fix and implement run as before and are not recorded in the ChatStore", async () => {
|
|
467
|
+
const r = await chatRig({ scenario: "ok_pr" });
|
|
468
|
+
try {
|
|
469
|
+
const a = r.assign({ kind: "implement", options: { createPullRequest: true }, inputs: [{ inline: `${CANARIES.request} add it`, transient: true }] });
|
|
470
|
+
assert.equal((await a.acked()).status, "accepted");
|
|
471
|
+
await r.done(a.p.jobId, 8000);
|
|
472
|
+
assert.equal(r.frames(a.p.jobId, "job.result")[0].payload.outcome, "pr_opened");
|
|
473
|
+
assert.equal(r.fake.jobs.get(a.p.jobId).kind, "implement");
|
|
474
|
+
assert.deepEqual(fs.readdirSync(path.join(r.chatDir, r.cp.tenantId)), [], "no conversation file for a non-chat job");
|
|
475
|
+
const q = await content(r, { kind: "job.stream", ref: a.p.jobId });
|
|
476
|
+
assert.equal(q.status, "not_found");
|
|
477
|
+
assert.equal(r.fake.requests.filter((x) => x.method === "STREAM").length, 0, "the content stream of agent2 is read for chats only");
|
|
478
|
+
} finally { await r.stop(); }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("agent2 is started with AGENT2_LOCAL_CHAT_ENABLED only when this agent runs the chat", () => {
|
|
482
|
+
const env = { PATH: "/usr/bin", HOME: "/home/x" };
|
|
483
|
+
const base = resolveAgent2RunnerConfig({ LOCAL_AGENT_RUNNER: "agent2", LOCAL_AGENT_AGENT2_CMD: '["java","-jar","a.jar"]' }, () => {});
|
|
484
|
+
const args = { env, tenantId: "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f", token: "t".repeat(43), bridgeBase: "http://127.0.0.1:1", callbackToken: "c".repeat(43) };
|
|
485
|
+
assert.equal(base.chat, false);
|
|
486
|
+
assert.equal(agent2ChildEnv({ config: base, ...args }).AGENT2_LOCAL_CHAT_ENABLED, undefined);
|
|
487
|
+
assert.equal(agent2ChildEnv({ config: { ...base, chat: true }, ...args }).AGENT2_LOCAL_CHAT_ENABLED, "true");
|
|
488
|
+
// an operator's own value in the environment of this agent is not passed on (the allowlist)
|
|
489
|
+
assert.equal(agent2ChildEnv({ config: base, ...args, env: { ...env, AGENT2_LOCAL_CHAT_ENABLED: "true" } }).AGENT2_LOCAL_CHAT_ENABLED, undefined);
|
|
490
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// LA-1 + LA-3, OPT-IN: the REAL agent2 (jar of visionhammer-agents release/qa with kind=chat, PR #313) started by this agent with AGENT2_LOCAL_CHAT_ENABLED, a real v2 session
|
|
2
|
+
// against the ReferenceCp, a FAKE AI gateway (the model concludes with conclude_no_change) and a stub of the LA /workspace API. Skipped unless AGENT2_JAR points at the jar
|
|
3
|
+
// (agents/software-engineer/target/software-engineer-*.jar, JDK 21) and a Java is found (AGENT2_JAVA or JAVA_HOME). Proves beyond the fakes: the real agent2 lists `chat` in
|
|
4
|
+
// /health, accepts kind=chat, its real content stream (`/jobs/{id}/stream`) is recorded and served by job.stream / chat.session, and `job.chat` is announced from that /health.
|
|
5
|
+
// Does not prove: a real model, a real repository, the gateway.
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { closeAllFakes, okMessages, byPath } from "./helpers/fake-llm.js";
|
|
13
|
+
import { agent2Rig, CANARIES, waitFor, uuid } from "./helpers/agent2-rig.js";
|
|
14
|
+
import { loadContract } from "../../src/la-protocol-v2/contract.js";
|
|
15
|
+
|
|
16
|
+
after(closeAllFakes);
|
|
17
|
+
const JAR = process.env.AGENT2_JAR || "";
|
|
18
|
+
const JAVA = process.env.AGENT2_JAVA || (process.env.JAVA_HOME ? `${process.env.JAVA_HOME}/bin/java` : "");
|
|
19
|
+
const skip = !JAR || !fs.existsSync(JAR) || !JAVA || !fs.existsSync(JAVA) ? "set AGENT2_JAR (and AGENT2_JAVA or JAVA_HOME) to run the real agent2 chat end to end" : false;
|
|
20
|
+
const contract = loadContract();
|
|
21
|
+
|
|
22
|
+
async function fakeLaWorkspace() {
|
|
23
|
+
const server = http.createServer((req, res) => { req.resume(); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, success: true, files: [], content: "" })); });
|
|
24
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
25
|
+
return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("REAL agent2 chat: /health lists chat -> job.chat announced -> kind=chat -> content stream recorded -> job.stream + chat.session; metadata only on the job frames", { skip, timeout: 300_000 }, async () => {
|
|
29
|
+
const la = await fakeLaWorkspace();
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "la-chat-real-"));
|
|
31
|
+
const conclude = okMessages("", { blocks: [{ type: "tool_use", id: "toolu_1", name: "conclude_no_change", input: { answer: CANARIES.answer, reason: "informational" } }], promptTokens: 120, completionTokens: 20 });
|
|
32
|
+
const r = await agent2Rig({ spawn: { cmd: [JAVA, "-Xmx512m", "-jar", JAR], env: { LOCAL_AGENT_AGENT2_START_TIMEOUT_MS: "150000", LOCAL_AGENT_AGENT2_LA_URL: la.url } },
|
|
33
|
+
behavior: byPath({ messages: conclude }),
|
|
34
|
+
env: { LOCAL_AGENT_CHAT: "true", LOCAL_AGENT_CHAT_DIR: dir, LOCAL_AGENT_CHAT_PROBE_MS: "170000", LOCAL_AGENT_CHAT_POLL_MS: "2000", LOCAL_AGENT_LLM_MAX_PROMPT_BYTES: String(2 * 1024 * 1024), LOCAL_AGENT_JOBS_CANCEL_GRACE_MS: "20000", LOCAL_AGENT_AGENT2_CANCEL_WAIT_MS: "15000" } });
|
|
35
|
+
try {
|
|
36
|
+
assert.ok(r.cp.registers[0].capabilities.includes("job.chat"), `job.chat announced from the real /health: ${JSON.stringify(r.cp.registers[0].capabilities)}`);
|
|
37
|
+
const a = r.assign({ kind: "chat", incidentRef: undefined, inputs: [{ inline: `${CANARIES.request} explain the greeting`, transient: true }], budget: { tokens: 200_000, seconds: 200 } });
|
|
38
|
+
assert.equal((await a.acked()).status, "accepted");
|
|
39
|
+
await r.done(a.p.jobId, 200_000);
|
|
40
|
+
const res = r.frames(a.p.jobId, "job.result")[0].payload;
|
|
41
|
+
assert.deepEqual([res.outcome, res.prUrl], ["no_change", undefined], JSON.stringify(r.link.agent2.audit.tail(20)));
|
|
42
|
+
// read the stream
|
|
43
|
+
const events = [];
|
|
44
|
+
let ref = a.p.jobId;
|
|
45
|
+
for (let n = 0; n < 40; n++) {
|
|
46
|
+
const requestId = uuid();
|
|
47
|
+
r.cp.sendData("content.request", { kind: "job.stream", ref, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
|
|
48
|
+
await waitFor(() => r.cp.received.some((f) => f.type === "content.response" && f.requestId === requestId && f.chunk?.last), 20_000);
|
|
49
|
+
const body = JSON.parse(Buffer.concat(r.cp.contentRequests.get(requestId).chunks).toString("utf8"));
|
|
50
|
+
assert.equal(contract.validateBody("job.stream.body.schema.json", body), true);
|
|
51
|
+
events.push(...body.events);
|
|
52
|
+
ref = body.nextRef;
|
|
53
|
+
if (body.done) break;
|
|
54
|
+
}
|
|
55
|
+
const types = events.map((e) => { try { return JSON.parse(e.e).type; } catch { return "?"; } });
|
|
56
|
+
assert.ok(types.includes("session_start") && types.at(-1) === "summary", types.join(","));
|
|
57
|
+
assert.ok(events.some((e) => e.e.includes(CANARIES.answer)), "the answer is in the recorded stream");
|
|
58
|
+
const requestId = uuid();
|
|
59
|
+
r.cp.sendData("content.request", { kind: "chat.session", ref: a.p.jobId, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
|
|
60
|
+
await waitFor(() => r.cp.received.some((f) => f.type === "content.response" && f.requestId === requestId && f.chunk?.last), 20_000);
|
|
61
|
+
const s = JSON.parse(Buffer.concat(r.cp.contentRequests.get(requestId).chunks).toString("utf8"));
|
|
62
|
+
assert.equal(contract.validateBody("chat.session.body.schema.json", s), true);
|
|
63
|
+
assert.deepEqual(s.turns.map((t) => t.role), ["user", "assistant"]);
|
|
64
|
+
assert.match(s.turns[1].text, new RegExp(CANARIES.answer));
|
|
65
|
+
const wire = JSON.stringify(r.cp.received.filter((f) => f.type !== "content.response"));
|
|
66
|
+
for (const c of [CANARIES.request, CANARIES.answer]) assert.ok(!wire.includes(c), `content on a job frame: ${c}`);
|
|
67
|
+
assert.deepEqual(r.cp.errorsSent, []);
|
|
68
|
+
} finally { await r.stop(); await la.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
69
|
+
});
|