agentus 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
// Mock ACP agent for offline development & browser QA.
|
|
2
|
+
// Implements just enough of the ACP agent surface (initialize/newSession/prompt/
|
|
3
|
+
// cancel/setSessionMode + permission flow) with fake streaming, so the whole
|
|
4
|
+
// Agentus pipeline can be exercised without a real LLM.
|
|
5
|
+
//
|
|
6
|
+
// Env switches to simulate edge cases (append `=N` to env vars when spawning):
|
|
7
|
+
// MOCK_TOOL=1 -> prompt triggers a tool_call + requestPermission
|
|
8
|
+
// MOCK_THINK=1 -> emits agent_thought_chunk before the answer
|
|
9
|
+
// MOCK_SLOW_MS=n -> delay between chunks (default 60)
|
|
10
|
+
// MOCK_SINK=1 -> exit the process mid-turn (crash path for AC5/QA)
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { AgentSideConnection, RequestError, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
14
|
+
import { Readable, Transform, Writable } from "node:stream";
|
|
15
|
+
|
|
16
|
+
const SLOW = Number(process.env.MOCK_SLOW_MS || 60);
|
|
17
|
+
|
|
18
|
+
// The file a `[tool-diff]` turn pretends to edit: hermes hands the cockpit the whole file
|
|
19
|
+
// before and after, so the approval surface has a path and a real change to show.
|
|
20
|
+
const MOCK_EDIT = {
|
|
21
|
+
path: "/tmp/mock-approval/demo-edit.txt",
|
|
22
|
+
oldText: "const greeting = \"hello\";\nconsole.log(greeting);\n",
|
|
23
|
+
newText: "const greeting = \"你好\";\nconsole.log(greeting, Date.now());\n",
|
|
24
|
+
};
|
|
25
|
+
/** A file long enough that reprinting it twice is exactly the "diff 太多了" complaint: 40 lines
|
|
26
|
+
* of unchanged preamble, three changed ones, 40 unchanged lines after. Hermes' real edit
|
|
27
|
+
* approval sends the whole file both ways, so the cockpit has to reduce this on its own. */
|
|
28
|
+
const MOCK_HERMES_EDIT = await (async () => {
|
|
29
|
+
const preamble = Array.from({ length: 40 }, (_, i) => `// filler line ${i + 1}: unchanged context the operator never needs to read`);
|
|
30
|
+
const tail = Array.from({ length: 40 }, (_, i) => `// tail line ${i + 1}: also unchanged`);
|
|
31
|
+
const head = [...preamble, 'const greeting = "hello";', "console.log(greeting);", ""].join("\n");
|
|
32
|
+
return {
|
|
33
|
+
path: "/tmp/mock-approval/hermes-real-shape.txt",
|
|
34
|
+
oldText: head + tail.join("\n"),
|
|
35
|
+
newText: head.split("\n").map((l) => (l === 'const greeting = "hello";' ? 'const greeting = "你好";' : l))
|
|
36
|
+
.join("\n") + tail.join("\n"),
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
let seq = 0;
|
|
40
|
+
const sessions = new Map();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* QA hook for the cockpit's plan tool (design-plan-service.md §6-B).
|
|
44
|
+
*
|
|
45
|
+
* The tool is INJECTED through the handshake, and the process that would actually spawn it is the
|
|
46
|
+
* AGENT — hermes does, this mock never does. So an end-to-end test of the write path needs that
|
|
47
|
+
* token from somewhere, and the obvious place (a mock reply) is a transcript row: a credential in
|
|
48
|
+
* the operator's UI. Opt-in instead: name a file in MOCK_PLAN_TOKEN_FILE and it lands there, 0600.
|
|
49
|
+
*/
|
|
50
|
+
function notePlanTool(mcpServers) {
|
|
51
|
+
const file = process.env.MOCK_PLAN_TOKEN_FILE;
|
|
52
|
+
if (!file || !Array.isArray(mcpServers)) return;
|
|
53
|
+
const tool = mcpServers.find((m) => m?.name === "agentus-plan");
|
|
54
|
+
if (!tool) return;
|
|
55
|
+
const env = Object.fromEntries((tool.env ?? []).map((e) => [e.name, e.value]));
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(file, JSON.stringify({ ...env, command: tool.command, args: tool.args ?? [] }), { mode: 0o600 });
|
|
58
|
+
} catch (e) {
|
|
59
|
+
process.stderr.write(`[mock-agent] could not write ${file}: ${e.message}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
const send = async (conn, sid, update) => {
|
|
65
|
+
await conn.sessionUpdate({ sessionId: sid, update });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Exercises every branch of the renderer the operators care about.
|
|
69
|
+
const MOCK_MARKDOWN = [
|
|
70
|
+
"",
|
|
71
|
+
"### mock markdown sample",
|
|
72
|
+
"",
|
|
73
|
+
"A list, a table and a fence:",
|
|
74
|
+
"",
|
|
75
|
+
"1. first — with `inline code`",
|
|
76
|
+
"2. second — **bold**, *italic*, and a [link](https://example.com/docs)",
|
|
77
|
+
" - nested bullet",
|
|
78
|
+
"",
|
|
79
|
+
"| knob | effect |",
|
|
80
|
+
"| --- | --- |",
|
|
81
|
+
"| mode | what needs approval |",
|
|
82
|
+
"| depth | how long it thinks |",
|
|
83
|
+
"",
|
|
84
|
+
"> a blockquote, as agents write when they cite something",
|
|
85
|
+
"",
|
|
86
|
+
// A soft break: one newline in the source, one <br> on screen (breaks: true). The
|
|
87
|
+
// cockpit's markdown container must not ALSO preserve the newline character — that is
|
|
88
|
+
// the difference between one line and two, and it is worth a permanent test fixture.
|
|
89
|
+
"A single newline here \u2192",
|
|
90
|
+
"and the next line follows it.",
|
|
91
|
+
"",
|
|
92
|
+
"```ts",
|
|
93
|
+
"// highlighting is only visible if this is colourful",
|
|
94
|
+
"const slot = (x: number) => x * 2;",
|
|
95
|
+
"export type Slot = ReturnType<typeof slot>;",
|
|
96
|
+
"```",
|
|
97
|
+
"",
|
|
98
|
+
"```bash",
|
|
99
|
+
"$ npm run workspace-smoke",
|
|
100
|
+
"```",
|
|
101
|
+
"",
|
|
102
|
+
"<img src=x onerror=\"alert('xss')\" /> <- must stay literal text",
|
|
103
|
+
"<script>alert('xss')</script> <- likewise",
|
|
104
|
+
"",
|
|
105
|
+
].join("\n");
|
|
106
|
+
|
|
107
|
+
const MODES = [
|
|
108
|
+
{ id: "default", name: "Default", description: "Ask before edits" },
|
|
109
|
+
{ id: "accept_edits", name: "Accept Edits", description: "Auto-approve file edits" },
|
|
110
|
+
{ id: "dont_ask", name: "Yolo", description: "Never ask" },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// One shape for "what this session currently runs with", shared by newSession and
|
|
114
|
+
// loadSession so a resume re-announces the session's OWN config instead of a default.
|
|
115
|
+
const configOptionsFor = (config) => [
|
|
116
|
+
// `category` is ACP's placement hint (the cockpit uses it to give a knob its own
|
|
117
|
+
// button). Real backends may omit it — the UI falls back to the id — but the mock
|
|
118
|
+
// advertises it so the placement path is exercised offline.
|
|
119
|
+
{ id: "reasoning_effort", name: "Reasoning Effort", type: "select", category: "thought_level",
|
|
120
|
+
currentValue: config.reasoning_effort || "medium",
|
|
121
|
+
options: [{ value: "low", name: "Low" }, { value: "medium", name: "Medium" }, { value: "high", name: "High" }] },
|
|
122
|
+
// The second typed option a real Hermes session advertises (acp_adapter/server.py): the window
|
|
123
|
+
// Hermes budgets its compression against. The cockpit renders it generically in chat settings,
|
|
124
|
+
// and the usage popover points at it instead of pretending its own number configures the model.
|
|
125
|
+
// `_meta.freeform` + a `_`-prefixed category mirror the real adapter: the presets are shortcuts,
|
|
126
|
+
// and a hand-typed window comes back as its own option so `currentValue` stays in the list.
|
|
127
|
+
{ id: "context_budget", name: "Context budget", type: "select", category: "_context_window",
|
|
128
|
+
currentValue: config.context_budget || "auto",
|
|
129
|
+
_meta: { freeform: true, unit: "tokens", min: 16384, step: 1024, scope: "model",
|
|
130
|
+
presets: [262144, 524288, 786432, 1048576] },
|
|
131
|
+
options: [{ value: "auto", name: "Auto (model window)" }, { value: "262144", name: "256K" },
|
|
132
|
+
{ value: "524288", name: "512K" }, { value: "786432", name: "768K" }, { value: "1048576", name: "1M" }] },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// ACP carries the session's model list as `models` on newSession/loadSession (the field
|
|
136
|
+
// the cockpit renders as its model button). MOCK_MODELS=1 keeps the list small.
|
|
137
|
+
const MODELS = [
|
|
138
|
+
{ modelId: "mock:fast", name: "Mock Fast", description: "low latency, shallow" },
|
|
139
|
+
{ modelId: "mock:deep", name: "Mock Deep", description: "slow, thorough" },
|
|
140
|
+
{ modelId: "mock:vision", name: "Mock Vision", description: "accepts images" },
|
|
141
|
+
// A real agent's list is WIDER than the set it will actually accept — hermes lists every
|
|
142
|
+
// provider/model it knows (live listing ∪ curated ∪ models.dev) and then validates a switch
|
|
143
|
+
// against a narrower pair of sources, so "it is in the picker" does not mean "it can be
|
|
144
|
+
// selected" (track §61: 33 of 503 entries on the live slot were like this, and the refusal
|
|
145
|
+
// was reported as the four words "Invalid params"). `mock:gone` is that fixture, and it is
|
|
146
|
+
// what the cockpit's refusal path (reason shown in place, picker stays open) is tested on.
|
|
147
|
+
{ modelId: "mock:gone", name: "Mock Gone", description: "listed, but this agent refuses it" },
|
|
148
|
+
];
|
|
149
|
+
const REJECTED = new Set(["mock:gone"]);
|
|
150
|
+
// The sentence hermes puts in the JSON-RPC error's `data.details` for a refused switch
|
|
151
|
+
// (acp_adapter/server.py: RequestError.invalid_params({"details": str(exc)})). Copied verbatim
|
|
152
|
+
// so the cockpit is tested against the real shape, not a friendlier one.
|
|
153
|
+
const rejectDetails = (modelId) =>
|
|
154
|
+
`Model \`${modelId}\` was not found in this provider's model listing.\n` +
|
|
155
|
+
" Similar models: `mock:fast`, `mock:deep`";
|
|
156
|
+
const modelsFor = (config) => ({
|
|
157
|
+
currentModelId: config.model || "mock:fast",
|
|
158
|
+
availableModels: MODELS,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const agent = () => ({
|
|
162
|
+
async initialize() {
|
|
163
|
+
return {
|
|
164
|
+
protocolVersion: 1,
|
|
165
|
+
agentCapabilities: {
|
|
166
|
+
loadSession: true,
|
|
167
|
+
// `fork` was implemented below but never advertised, so a conformant client could
|
|
168
|
+
// not use it. The cockpit's "regenerate the session name from the conversation"
|
|
169
|
+
// feature is such a client — it forks the session, asks the fork to summarise, and
|
|
170
|
+
// throws the fork away. Advertising it makes that path testable offline.
|
|
171
|
+
sessionCapabilities: { list: {}, cancel: {}, fork: {} },
|
|
172
|
+
},
|
|
173
|
+
authMethods: [{ id: "mock", name: "Mock auth (no-op)" }],
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async newSession({ cwd, mcpServers }) {
|
|
178
|
+
const sessionId = `mock-${++seq}`;
|
|
179
|
+
sessions.set(sessionId, { cwd, cancelled: false, currentModeId: "default", config: {}, used: 0, history: [], mcp: mcpServers ?? [] });
|
|
180
|
+
notePlanTool(mcpServers);
|
|
181
|
+
// Announce slash commands the way a real agent does (available_commands_update),
|
|
182
|
+
// so the cockpit's palette path is exercised without a real backend.
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
void send(agent._conn, sessionId, {
|
|
185
|
+
sessionUpdate: "available_commands_update",
|
|
186
|
+
availableCommands: [
|
|
187
|
+
{ name: "help", description: "List available commands" },
|
|
188
|
+
// The two context commands Hermes advertises (probed 2026-10-03): the cockpit
|
|
189
|
+
// offers them next to the gauge, and they must be testable without a real agent.
|
|
190
|
+
{ name: "context", description: "Show conversation message counts by role" },
|
|
191
|
+
{ name: "compress", description: "Compress conversation context" },
|
|
192
|
+
{ name: "mock", description: "Mock-only no-op command" },
|
|
193
|
+
{ name: "slow", description: "Stream slowly for reconnect drills" },
|
|
194
|
+
],
|
|
195
|
+
}).catch(() => {});
|
|
196
|
+
}, 30);
|
|
197
|
+
return {
|
|
198
|
+
sessionId,
|
|
199
|
+
modes: { currentModeId: "default", availableModes: MODES },
|
|
200
|
+
configOptions: configOptionsFor({}),
|
|
201
|
+
models: modelsFor({}),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// A real agent restores its own session state on load (hermes re-reads the persisted
|
|
206
|
+
// reasoning_config). Keep the mock faithful: re-announce the SAME session instead of
|
|
207
|
+
// minting a fresh one, so a resume doesn't silently reset modes/config.
|
|
208
|
+
async loadSession({ sessionId, cwd, mcpServers }) {
|
|
209
|
+
// MOCK_FORGET=1 answers like a REAL agent that has never seen this session: hermes returns an
|
|
210
|
+
// EMPTY load result for an unknown id, and `refusal` on every prompt after (measured with
|
|
211
|
+
// scripts/probe-load-missing.mjs). Without it, the mock's job is the friendly case — "a cold
|
|
212
|
+
// resume works" — so it restores anything. With it, the cockpit's refusal path (a slot that can
|
|
213
|
+
// never come back → 409 → the operator is told, and offered the delete) is testable in CI
|
|
214
|
+
// without a real agent installed.
|
|
215
|
+
if (process.env.MOCK_FORGET === "1" && !sessions.has(sessionId)) return {};
|
|
216
|
+
// A resumed/forked session arrives in a FRESH process whose in-memory map is empty.
|
|
217
|
+
// A real agent reads the transcript from its store, so the id it is asked for is the
|
|
218
|
+
// id it ends up serving — the mock has to do the same, or every later call on that
|
|
219
|
+
// session ("no such session: mock-2") fails for reasons that have nothing to do with
|
|
220
|
+
// the cockpit. Register the id as the parent's stand-in instead of inventing a new one.
|
|
221
|
+
if (!sessions.has(sessionId)) {
|
|
222
|
+
sessions.set(sessionId, { cwd, cancelled: false, currentModeId: "default", config: {}, used: 0, restored: true, history: [], mcp: mcpServers ?? [] });
|
|
223
|
+
process.stderr.write(`[mock-agent] loadSession ${sessionId} -> restored (not in memory)\n`);
|
|
224
|
+
}
|
|
225
|
+
if (sessions.has(sessionId)) {
|
|
226
|
+
const s = sessions.get(sessionId);
|
|
227
|
+
s.mcp = mcpServers ?? s.mcp ?? [];
|
|
228
|
+
notePlanTool(mcpServers);
|
|
229
|
+
// ── MOCK_REPLAY=1: the REAL replay shape ────────────────────────────────────────────────
|
|
230
|
+
// Hermes re-sends its whole transcript on `session/load`: complete blocks (not chunks), and
|
|
231
|
+
// the reasoning ones carry NO messageId — so the cockpit cannot fold them by identity, and a
|
|
232
|
+
// store that appends them grows a row per block and stamps them all with the resume's clock.
|
|
233
|
+
// That is the shape measured on live on 2026-10-08 (1240 rows from one restart, every session
|
|
234
|
+
// pushed to 「刚刚」), so the mock has to be able to produce it for the rule to be testable.
|
|
235
|
+
if (process.env.MOCK_REPLAY === "1") {
|
|
236
|
+
const n = Math.max(1, Number(process.env.MOCK_REPLAY_BLOCKS || 4) || 4);
|
|
237
|
+
// Say it out loud: "the replay never ran" and "the replay was dropped" look identical from
|
|
238
|
+
// the outside, and only one of them is a passing test.
|
|
239
|
+
process.stderr.write(`[mock-agent] replaying ${n} blocks onto ${sessionId} (session/load)\n`);
|
|
240
|
+
for (let i = 0; i < n; i++) {
|
|
241
|
+
await send(agent._conn, sessionId, {
|
|
242
|
+
sessionUpdate: "agent_thought_chunk",
|
|
243
|
+
content: {
|
|
244
|
+
type: "text",
|
|
245
|
+
text: `replayed thought ${i}: `
|
|
246
|
+
+ "the agent restating its own reasoning at full length on a re-attach, with no id "
|
|
247
|
+
+ "to fold it by. ".repeat(3),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
await send(agent._conn, sessionId, {
|
|
251
|
+
sessionUpdate: "agent_message_chunk",
|
|
252
|
+
content: {
|
|
253
|
+
type: "text",
|
|
254
|
+
text: `replayed answer ${i}: ` + "the agent restating its own answer at full length. ".repeat(4),
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
sessionId,
|
|
261
|
+
modes: { currentModeId: s.currentModeId || "default", availableModes: MODES },
|
|
262
|
+
configOptions: configOptionsFor(s.config || {}),
|
|
263
|
+
models: modelsFor(s.config || {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return this.newSession({ cwd });
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/** ACP's model switch: `session/set_model` (the SDK in use does not type it, the wire
|
|
270
|
+
* still routes it here). */
|
|
271
|
+
async setSessionModel({ sessionId, modelId }) {
|
|
272
|
+
const s = sessions.get(sessionId);
|
|
273
|
+
if (!s) throw new Error("no such session");
|
|
274
|
+
if (REJECTED.has(modelId)) throw RequestError.invalidParams({ details: rejectDetails(modelId) });
|
|
275
|
+
if (!MODELS.some((m) => m.modelId === modelId)) throw new Error(`unknown model: ${modelId}`);
|
|
276
|
+
s.config = { ...(s.config || {}), model: modelId };
|
|
277
|
+
return { models: modelsFor(s.config) };
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async setSessionMode({ sessionId, modeId }) {
|
|
281
|
+
if (!sessions.has(sessionId)) throw new Error("no such session");
|
|
282
|
+
const s = sessions.get(sessionId);
|
|
283
|
+
s.currentModeId = modeId;
|
|
284
|
+
return {};
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async setSessionConfigOption({ sessionId, configId, value }) {
|
|
288
|
+
if (!sessions.has(sessionId)) throw new Error("no such session");
|
|
289
|
+
const s = sessions.get(sessionId);
|
|
290
|
+
// Shape for shape with the real adapter: the response carries the session's WHOLE rebuilt
|
|
291
|
+
// option list (not just the one that changed), and a value the option does not offer comes
|
|
292
|
+
// back with the OLD currentValue instead of being snapped to something else. The cockpit
|
|
293
|
+
// reads that list back as the truth, so a partial reply here would erase the surface.
|
|
294
|
+
const options = configOptionsFor(s.config || {});
|
|
295
|
+
const target = options.find((o) => o.id === configId);
|
|
296
|
+
const supported = (target?.options || []).map((o) => o.value);
|
|
297
|
+
// Free-form options (`_meta.freeform`) accept anything inside their floor; for the rest an
|
|
298
|
+
// unlisted value comes back with the old currentValue, shape for shape with the real adapter.
|
|
299
|
+
const freeform = target?._meta?.freeform === true;
|
|
300
|
+
if (freeform) {
|
|
301
|
+
const n = Number(String(value).replace(/[^0-9]/g, ""));
|
|
302
|
+
if (!(n > 0) || (typeof target._meta.min === "number" && n < target._meta.min)) {
|
|
303
|
+
return { configOptions: options };
|
|
304
|
+
}
|
|
305
|
+
} else if (supported.length && !supported.includes(String(value))) {
|
|
306
|
+
return { configOptions: options };
|
|
307
|
+
}
|
|
308
|
+
s.config = { ...(s.config || {}), [configId]: value };
|
|
309
|
+
// A hand-typed window must show up as an option, or the returned list contradicts itself.
|
|
310
|
+
const rebuilt = configOptionsFor(s.config || {});
|
|
311
|
+
const opt = rebuilt.find((o) => o.id === configId);
|
|
312
|
+
if (freeform && opt && !opt.options.some((o) => o.value === String(opt.currentValue))) {
|
|
313
|
+
opt.options = [...opt.options, { value: String(opt.currentValue), name: `${opt.currentValue} (custom)` }];
|
|
314
|
+
}
|
|
315
|
+
return { configOptions: rebuilt };
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async cancel({ sessionId }) {
|
|
319
|
+
const s = sessions.get(sessionId);
|
|
320
|
+
if (s) s.cancelled = true;
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
async prompt({ sessionId, prompt }) {
|
|
324
|
+
const s = sessions.get(sessionId);
|
|
325
|
+
if (!s) throw new Error("no such session");
|
|
326
|
+
s.cancelled = false;
|
|
327
|
+
const text = prompt.map((p) => p.text || "").join(" ");
|
|
328
|
+
|
|
329
|
+
// QA: report the SHAPE of what arrived, not just its text. A client may attach STRUCTURED
|
|
330
|
+
// blocks — the cockpit hands a resumed agent the session's plan as an ACP `resource` — and the
|
|
331
|
+
// difference between "the operator typed this" and "the client attached this" is exactly what a
|
|
332
|
+
// test needs to see. `text` above only reads `.text`, so a resource block is otherwise invisible
|
|
333
|
+
// to this mock (measured 2026-10-08: the plan hand-over could not be verified at all).
|
|
334
|
+
if (process.env.MOCK_BLOCKS === "1" || /\[blocks\]/.test(text)) {
|
|
335
|
+
const shape = prompt.map((p) => ({
|
|
336
|
+
type: p.type ?? "?",
|
|
337
|
+
resourceUri: p.resource?.uri ?? null,
|
|
338
|
+
// the attached text itself, so a test can assert WHICH plan was handed over
|
|
339
|
+
body: (p.resource?.text || p.text || "").slice(0, 600),
|
|
340
|
+
meta: p._meta ?? null,
|
|
341
|
+
}));
|
|
342
|
+
await send(agent._conn, sessionId, {
|
|
343
|
+
sessionUpdate: "agent_message_chunk",
|
|
344
|
+
content: { type: "text", text: "BLOCKS " + JSON.stringify(shape) },
|
|
345
|
+
});
|
|
346
|
+
return { stopReason: "end_turn" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// QA: what did the HANDSHAKE carry? The plan tool is injected per session, and its whole
|
|
350
|
+
// promise (§4) is that it exists HERE and nowhere else — so the declaration's shape is what a
|
|
351
|
+
// test asserts. Deliberately no env VALUES: that is where the per-session token lives.
|
|
352
|
+
if (/\[mcp\]/.test(text)) {
|
|
353
|
+
const shape = (s.mcp ?? []).map((m) => ({
|
|
354
|
+
name: m.name ?? null,
|
|
355
|
+
command: String(m.command ?? "").split("/").pop() ?? null,
|
|
356
|
+
args: (m.args ?? []).map((a) => String(a).split("/").pop()),
|
|
357
|
+
envNames: (m.env ?? []).map((e) => e.name),
|
|
358
|
+
}));
|
|
359
|
+
await send(agent._conn, sessionId, {
|
|
360
|
+
sessionUpdate: "agent_message_chunk",
|
|
361
|
+
content: { type: "text", text: "MCP " + JSON.stringify(shape) },
|
|
362
|
+
});
|
|
363
|
+
return { stopReason: "end_turn" };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A title request (the cockpit's 重新生成) is answered from what this session already
|
|
367
|
+
// knows. On a FORK that is the parent's copied transcript, so the count is the proof the
|
|
368
|
+
// copy arrived; a session with no parent cannot be a title fork at all.
|
|
369
|
+
const inherited = (s.history || []).length;
|
|
370
|
+
s.history = [...(s.history || []), text];
|
|
371
|
+
if (/会话标题/.test(text)) {
|
|
372
|
+
// A real model needs a moment; the delay is deliberate so a client's "working…" state
|
|
373
|
+
// is observable in a test instead of flashing past.
|
|
374
|
+
await sleep(Number(process.env.MOCK_TITLE_MS || 1200));
|
|
375
|
+
const answer = s.parent
|
|
376
|
+
? `继承 ${inherited} 轮上下文的标题`
|
|
377
|
+
: `没有父会话可继承的标题`;
|
|
378
|
+
await send(agent._conn, sessionId, {
|
|
379
|
+
sessionUpdate: "agent_message_chunk",
|
|
380
|
+
content: { type: "text", text: answer },
|
|
381
|
+
});
|
|
382
|
+
return { stopReason: "end_turn" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// QA triggers: prompt text flips behaviors per turn (env sets global defaults).
|
|
386
|
+
// Declared up front — used by the blocks below (a hoisting mistake here shows
|
|
387
|
+
// up as an opaque "-32603 Internal error" over ACP, QA#11).
|
|
388
|
+
const wantTool = process.env.MOCK_TOOL === "1" || /\[tool\]/.test(text) || /\[tool-diff\]/.test(text) || /\[tool-hermes\]/.test(text);
|
|
389
|
+
// A file EDIT, shaped like the real one: hermes' edit approval carries the whole file
|
|
390
|
+
// before and after as a `diff` content item (`acp.tool_diff_content`), and the cockpit's
|
|
391
|
+
// approval surface must be able to say WHICH file and WHAT changes from that alone.
|
|
392
|
+
const wantDiff = process.env.MOCK_DIFF === "1" || /\[tool-diff\]/.test(text);
|
|
393
|
+
// Hermes' REAL edit approval, shape for shape: two options only (`allow_once` "Allow edit"
|
|
394
|
+
// and `deny` "Deny"), toolCall title `Approve edit: <path>`, and the whole file before and
|
|
395
|
+
// after as one `diff` item — which is why the cockpit must collapse it (read off
|
|
396
|
+
// acp_adapter/edit_approval.py, 2026-10-06).
|
|
397
|
+
const wantHermes = process.env.MOCK_HERMES_APPROVAL === "1" || /\[tool-hermes\]/.test(text);
|
|
398
|
+
const wantThink = process.env.MOCK_THINK === "1" || /\[think(:\d+)?\]/.test(text);
|
|
399
|
+
const willSink = process.env.MOCK_SINK === "1" || /\[sink\]/.test(text);
|
|
400
|
+
const wantPlan = process.env.MOCK_PLAN === "1" || /\[plan\]/.test(text);
|
|
401
|
+
// per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
|
|
402
|
+
const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
|
|
403
|
+
|
|
404
|
+
// `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
|
|
405
|
+
// Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
|
|
406
|
+
// relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
|
|
407
|
+
// measurable claims instead of a screenshot someone has to squint at. The directory is
|
|
408
|
+
// overridable because the sweep creates its own fixtures.
|
|
409
|
+
if (/\[table\]/.test(text)) {
|
|
410
|
+
const dir = process.env.MOCK_TABLE_DIR || "/tmp/agentus-qa-files";
|
|
411
|
+
const rows = [
|
|
412
|
+
["zeta", "10", "1.2 MB"],
|
|
413
|
+
["alpha", "9", "900 kB"],
|
|
414
|
+
["mu", "100", "12 MB"],
|
|
415
|
+
["beta", "2", "3 kB"],
|
|
416
|
+
["kappa", "33", "44 kB"],
|
|
417
|
+
["gamma", "7", "8 kB"],
|
|
418
|
+
];
|
|
419
|
+
const reply = [
|
|
420
|
+
"### table fixture",
|
|
421
|
+
"",
|
|
422
|
+
"| name | count | size |",
|
|
423
|
+
"| --- | --- | --- |",
|
|
424
|
+
...rows.map((r) => `| ${r.join(" | ")} |`),
|
|
425
|
+
"",
|
|
426
|
+
`a local file: [note.md](file://${dir}/note.md#L2) and a bare ${dir}/app.py:2`,
|
|
427
|
+
"",
|
|
428
|
+
"",
|
|
429
|
+
"",
|
|
430
|
+
].join("\n");
|
|
431
|
+
await send(agent._conn, sessionId, {
|
|
432
|
+
sessionUpdate: "agent_message_chunk",
|
|
433
|
+
messageId: randomUUID(),
|
|
434
|
+
content: { type: "text", text: reply },
|
|
435
|
+
});
|
|
436
|
+
return { stopReason: "end_turn" };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (wantThink) {
|
|
440
|
+
// ONE `messageId` per burst, like the real adapter sends: ACP gives every chunk of one
|
|
441
|
+
// thought block the same id, and the cockpit/server coalesce on it (`block_key`). A mock
|
|
442
|
+
// that omits it makes each chunk its own row — a transcript shape that only exists in QA
|
|
443
|
+
// (measured 2026-10-06: 1336 rows in the DB for a 10-burst turn).
|
|
444
|
+
const thinkId = `th-${sessionId}-${Date.now()}`;
|
|
445
|
+
// `[think:N]` streams N words instead of the default sentence: the long-thinking case (the
|
|
446
|
+
// operator's 「思考卡住不动、然后突然全刷出来」) needs a stream long enough that the reader can
|
|
447
|
+
// SEE whether it arrives progressively or in one lump.
|
|
448
|
+
const asked = /\[think:(\d+)\]/.exec(text);
|
|
449
|
+
const words = asked
|
|
450
|
+
? Array.from({ length: Number(asked[1]) }, (_, i) => `思考片段${i}`)
|
|
451
|
+
: "pondering the user's request very deeply".split(" ");
|
|
452
|
+
for (const w of words) {
|
|
453
|
+
await send(agent._conn, sessionId, {
|
|
454
|
+
sessionUpdate: "agent_thought_chunk",
|
|
455
|
+
messageId: thinkId,
|
|
456
|
+
content: { type: "text", text: w + " " },
|
|
457
|
+
});
|
|
458
|
+
await sleep(SLOW);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A turn that does real work: N tool calls with a reasoning burst before each — the
|
|
463
|
+
// transcript-flood case (ten calls + ten bursts). `[tools:10]` drives it with no permission
|
|
464
|
+
// prompts: the point is the SHAPE of the transcript, not the approval.
|
|
465
|
+
const many = /\[tools:(\d+)\]/.exec(text);
|
|
466
|
+
if (many) {
|
|
467
|
+
const n = Math.min(20, Math.max(1, Number(many[1]) || 1));
|
|
468
|
+
for (let i = 1; i <= n; i++) {
|
|
469
|
+
const burst = (`step ${i}: reading the file, checking the surrounding code, then writing `
|
|
470
|
+
+ "the change back. This sentence is deliberately long so the reasoning block is taller "
|
|
471
|
+
+ "than the small window the cockpit draws for it, which is what makes the scrolling "
|
|
472
|
+
+ "behaviour measurable at all. ").repeat(3);
|
|
473
|
+
const thinkId = `th-${sessionId}-multi-${i}`;
|
|
474
|
+
for (const word of burst.split(" ")) {
|
|
475
|
+
await send(agent._conn, sessionId, {
|
|
476
|
+
sessionUpdate: "agent_thought_chunk",
|
|
477
|
+
messageId: thinkId,
|
|
478
|
+
content: { type: "text", text: word + " " },
|
|
479
|
+
});
|
|
480
|
+
await sleep(10);
|
|
481
|
+
}
|
|
482
|
+
const id = `tc-${sessionId}-multi-${i}`;
|
|
483
|
+
await send(agent._conn, sessionId, {
|
|
484
|
+
sessionUpdate: "tool_call",
|
|
485
|
+
toolCallId: id,
|
|
486
|
+
title: i % 3 === 0 ? `bash: run the suite (step ${i})` : i % 2 === 0 ? `read_file src/app-${i}.ts` : `grep -n TODO src/ (step ${i})`,
|
|
487
|
+
kind: i % 3 === 0 ? "execute" : "read",
|
|
488
|
+
status: "in_progress",
|
|
489
|
+
rawInput: { step: i },
|
|
490
|
+
});
|
|
491
|
+
await sleep(25);
|
|
492
|
+
await send(agent._conn, sessionId, {
|
|
493
|
+
sessionUpdate: "tool_call_update",
|
|
494
|
+
toolCallId: id,
|
|
495
|
+
status: "completed",
|
|
496
|
+
rawOutput: `step ${i} done (${i * 7} bytes)`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (wantTool) {
|
|
502
|
+
const toolCallId = `tc-${sessionId}-${Date.now()}`;
|
|
503
|
+
const edit = wantHermes ? MOCK_HERMES_EDIT : MOCK_EDIT;
|
|
504
|
+
const withDiff = wantDiff || wantHermes;
|
|
505
|
+
const diffContent = withDiff
|
|
506
|
+
? [{
|
|
507
|
+
type: "diff", path: edit.path, oldText: edit.oldText, newText: edit.newText,
|
|
508
|
+
}]
|
|
509
|
+
: undefined;
|
|
510
|
+
await send(agent._conn, sessionId, {
|
|
511
|
+
sessionUpdate: "tool_call", toolCallId,
|
|
512
|
+
title: withDiff ? `Edit file: ${edit.path}` : `Write file: ./demo-${Math.floor(Math.random() * 1e4)}.txt`,
|
|
513
|
+
kind: "edit", status: "pending",
|
|
514
|
+
// shaped like a real agent: input args on the call, output on the update
|
|
515
|
+
...(diffContent ? { content: diffContent } : {}),
|
|
516
|
+
rawInput: withDiff
|
|
517
|
+
? { tool: "patch", arguments: { path: edit.path } }
|
|
518
|
+
: { path: "./demo.txt", content: "hello from the mock agent" },
|
|
519
|
+
});
|
|
520
|
+
const mode = s.currentModeId || "default";
|
|
521
|
+
if (mode !== "dont_ask" && mode !== "accept_edits") {
|
|
522
|
+
const resp = await agent._conn.requestPermission({
|
|
523
|
+
sessionId,
|
|
524
|
+
toolCall: {
|
|
525
|
+
toolCallId,
|
|
526
|
+
title: wantHermes ? `Approve edit: ${edit.path}` : withDiff ? `Approve edit: ${edit.path}` : "Write file",
|
|
527
|
+
kind: "edit",
|
|
528
|
+
...(diffContent ? { content: diffContent } : {}),
|
|
529
|
+
},
|
|
530
|
+
options: wantHermes
|
|
531
|
+
? [
|
|
532
|
+
{ optionId: "allow_once", name: "Allow edit", kind: "allow_once" },
|
|
533
|
+
{ optionId: "deny", name: "Deny", kind: "reject_once" },
|
|
534
|
+
]
|
|
535
|
+
: [
|
|
536
|
+
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
|
537
|
+
{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" },
|
|
538
|
+
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
|
539
|
+
],
|
|
540
|
+
});
|
|
541
|
+
const chosen = resp?.outcome?.optionId;
|
|
542
|
+
await send(agent._conn, sessionId, {
|
|
543
|
+
sessionUpdate: "tool_call_update", toolCallId,
|
|
544
|
+
status: chosen === "reject" || chosen === "deny" ? "failed" : "completed",
|
|
545
|
+
// the output half of the card (AionUi F-DISPLAY-03 wants it viewable)
|
|
546
|
+
rawOutput: chosen === "reject" || chosen === "deny"
|
|
547
|
+
? "rejected by the operator — nothing written"
|
|
548
|
+
: `wrote 24 bytes to ./demo.txt (chosen option: ${chosen})`,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (wantPlan) {
|
|
554
|
+
// ACP requires content+priority+status on every PlanEntry: @agentclientprotocol/sdk
|
|
555
|
+
// silently DROPS entries that fail validation (spec-conformant, but hostile — the
|
|
556
|
+
// operator would just see a shorter plan). Always send all three.
|
|
557
|
+
await send(agent._conn, sessionId, {
|
|
558
|
+
sessionUpdate: "plan",
|
|
559
|
+
entries: [
|
|
560
|
+
{ content: "read the failing test", status: "completed", priority: "medium" },
|
|
561
|
+
{ content: "patch the flaky timing assert", status: "in_progress", priority: "high" },
|
|
562
|
+
{ content: "run full suite", status: "pending", priority: "medium" },
|
|
563
|
+
],
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (process.env.MOCK_PLAN_V2 === "1" || /\[planv2\]/.test(text)) {
|
|
567
|
+
// ACP v2's `plan_update` (draft): one level deeper than v1, plus the `planId` the v2 schema
|
|
568
|
+
// adds. NOTE: v2 documents an extra `cancelled` status, but THIS SDK revision's
|
|
569
|
+
// PlanEntryStatus is still pending|in_progress|completed and its validator silently DROPS an
|
|
570
|
+
// entry that fails it (the same trap the v1 block above warns about) — so a status the schema
|
|
571
|
+
// does not know cannot be put on this wire from here. The ingest's tolerance of unknown
|
|
572
|
+
// statuses is covered where it can be: scripts/qa/plan-v2-sweep.mts, at the unit level.
|
|
573
|
+
await send(agent._conn, sessionId, {
|
|
574
|
+
sessionUpdate: "plan_update",
|
|
575
|
+
plan: {
|
|
576
|
+
planId: "main",
|
|
577
|
+
type: "items",
|
|
578
|
+
entries: [
|
|
579
|
+
{ content: "v2 read the failing test", status: "completed", priority: "medium" },
|
|
580
|
+
{ content: "v2 patch the flaky timing assert", status: "pending", priority: "high" },
|
|
581
|
+
{ content: "v2 run full suite", status: "in_progress", priority: "medium" },
|
|
582
|
+
],
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const effort = s.config?.reasoning_effort || "medium";
|
|
587
|
+
// One id per logical message, exactly like the real backend: the cockpit (and the store) fold the
|
|
588
|
+
// chunks of a message by this id, so a mock that omits it would test a path nobody runs.
|
|
589
|
+
const messageId = randomUUID();
|
|
590
|
+
const words = `Mock echo (${effort} effort) to: "${text}". `.repeat(2).split(" ");
|
|
591
|
+
for (const w of words) {
|
|
592
|
+
if (s.cancelled) return { stopReason: "cancelled" };
|
|
593
|
+
if (willSink && w.length > 15) process.exit(7);
|
|
594
|
+
await send(agent._conn, sessionId, {
|
|
595
|
+
sessionUpdate: "agent_message_chunk",
|
|
596
|
+
messageId,
|
|
597
|
+
content: { type: "text", text: w + " " },
|
|
598
|
+
});
|
|
599
|
+
await sleep(slow);
|
|
600
|
+
}
|
|
601
|
+
// A markdown sample: the cockpit renders replies as markdown (headings, lists,
|
|
602
|
+
// tables, quotes, code with highlighting), so the mock emits all of it — offline QA
|
|
603
|
+
// of the renderer needs no real model.
|
|
604
|
+
await send(agent._conn, sessionId, {
|
|
605
|
+
sessionUpdate: "agent_message_chunk",
|
|
606
|
+
messageId,
|
|
607
|
+
content: { type: "text", text: MOCK_MARKDOWN },
|
|
608
|
+
});
|
|
609
|
+
// Context gauge data, shaped like a real agent's usage_update (AionUi F-DISPLAY-07).
|
|
610
|
+
// `/compress` is the agent's own command for this: honour it by actually lowering the
|
|
611
|
+
// reported context, so "the button did something" is a measurable claim.
|
|
612
|
+
const compress = /^\/compress\b/.test(text.trim());
|
|
613
|
+
s.used = compress ? Math.ceil((s.used || 4000) / 5) : (s.used || 0) + text.length + 120;
|
|
614
|
+
// The reported window follows a picked context_budget, like the real adapter (whose
|
|
615
|
+
// `usage_update.size` IS the window it compresses against) — otherwise a cockpit that
|
|
616
|
+
// switches its window would look like nothing happened.
|
|
617
|
+
const picked = Number((s.config || {}).context_budget);
|
|
618
|
+
await send(agent._conn, sessionId, {
|
|
619
|
+
sessionUpdate: "usage_update",
|
|
620
|
+
used: s.used,
|
|
621
|
+
size: picked > 0 ? picked : Number(process.env.MOCK_USAGE_SIZE || 200_000),
|
|
622
|
+
});
|
|
623
|
+
return { stopReason: "end_turn" };
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// The TS SDK in use routes a fixed method list and does NOT know `session/set_model`
|
|
628
|
+
// (ACP's model switch — the Python SDK hermes uses does). The cockpit sends that method
|
|
629
|
+
// anyway, so the mock answers it itself: watch for it on stdin, reply, and swallow the
|
|
630
|
+
// line instead of handing it to a router that would answer "method not found".
|
|
631
|
+
const STDIN_INTERCEPTED = new Set(["session/set_model", "session/fork"]);
|
|
632
|
+
const stdinFilter = new Transform({
|
|
633
|
+
transform(chunk, _enc, cb) {
|
|
634
|
+
const out = [];
|
|
635
|
+
for (const line of String(chunk).split("\n")) {
|
|
636
|
+
if (!line.trim()) continue;
|
|
637
|
+
let msg;
|
|
638
|
+
try { msg = JSON.parse(line); } catch { out.push(line); continue; }
|
|
639
|
+
if (msg?.method === "session/fork") {
|
|
640
|
+
// ACP fork: a new session seeded with the parent's transcript (the deep copy a
|
|
641
|
+
// real agent does server-side; the mock only needs the observable shape).
|
|
642
|
+
const params = msg.params ?? {};
|
|
643
|
+
const parent = sessions.get(params.sessionId);
|
|
644
|
+
if (!parent) {
|
|
645
|
+
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: `no such session: ${params.sessionId}` } }) + "\n");
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const forkId = `mock-${++seq}`;
|
|
649
|
+
sessions.set(forkId, { ...parent, parent: params.sessionId, currentModeId: parent.currentModeId, config: { ...(parent.config || {}) } });
|
|
650
|
+
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {
|
|
651
|
+
sessionId: forkId,
|
|
652
|
+
modes: { currentModeId: parent.currentModeId || "default", availableModes: MODES },
|
|
653
|
+
configOptions: configOptionsFor(parent.config || {}),
|
|
654
|
+
models: modelsFor(parent.config || {}),
|
|
655
|
+
} }) + "\n");
|
|
656
|
+
process.stderr.write(`[mock-agent] session/fork ${params.sessionId} -> ${forkId}\n`);
|
|
657
|
+
continue; // never forwarded: this SDK version does not route the method
|
|
658
|
+
}
|
|
659
|
+
if (msg?.method && STDIN_INTERCEPTED.has(msg.method)) {
|
|
660
|
+
const params = msg.params ?? {};
|
|
661
|
+
const target = sessions.get(params.sessionId);
|
|
662
|
+
const gone = REJECTED.has(params.modelId);
|
|
663
|
+
const ok = Boolean(target) && !gone && MODELS.some((m) => m.modelId === params.modelId);
|
|
664
|
+
if (ok) target.config = { ...(target.config || {}), model: params.modelId };
|
|
665
|
+
// A refusal carries the reason in `data.details`, exactly like hermes does — the
|
|
666
|
+
// cockpit must be able to show the sentence, not just the protocol title.
|
|
667
|
+
const reply = ok
|
|
668
|
+
? { jsonrpc: "2.0", id: msg.id, result: { models: modelsFor(target.config) } }
|
|
669
|
+
: {
|
|
670
|
+
jsonrpc: "2.0", id: msg.id,
|
|
671
|
+
error: {
|
|
672
|
+
code: -32602, message: "Invalid params",
|
|
673
|
+
data: { details: gone ? rejectDetails(params.modelId) : `unknown model: ${params.modelId}` },
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
process.stdout.write(JSON.stringify(reply) + "\n");
|
|
677
|
+
process.stderr.write(`[mock-agent] ${msg.method} -> ${ok ? "ok" : "rejected"} (${params.modelId})\n`);
|
|
678
|
+
continue; // never forwarded: the SDK would 404 it
|
|
679
|
+
}
|
|
680
|
+
out.push(line);
|
|
681
|
+
}
|
|
682
|
+
cb(null, out.length ? out.join("\n") + "\n" : "");
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const conn = new AgentSideConnection((c) => {
|
|
687
|
+
const a = agent();
|
|
688
|
+
a._conn = c;
|
|
689
|
+
agent._conn = c;
|
|
690
|
+
return a;
|
|
691
|
+
}, ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin.pipe(stdinFilter))));
|
|
692
|
+
void conn;
|
|
693
|
+
process.stderr.write("[mock-agent] ready on stdio\n");
|