@botcord/daemon 0.2.49 → 0.2.51

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.
@@ -0,0 +1,305 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { buildCliEnv } from "../cli-resolver.js";
3
+ import { NdjsonStreamAdapter } from "./ndjson-stream.js";
4
+ import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
5
+ function isValidKimiSessionId(sessionId) {
6
+ if (sessionId.length === 0 || sessionId.length > 512)
7
+ return false;
8
+ if (sessionId.startsWith("-"))
9
+ return false;
10
+ for (const ch of sessionId) {
11
+ const code = ch.codePointAt(0);
12
+ if (code === undefined || code < 0x20 || code === 0x7f)
13
+ return false;
14
+ }
15
+ return true;
16
+ }
17
+ function invalidKimiSessionIdError() {
18
+ return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
19
+ }
20
+ const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
21
+ "--add-dir",
22
+ "--agent",
23
+ "--agent-file",
24
+ "--config",
25
+ "--config-file",
26
+ "--max-ralph-iterations",
27
+ "--max-retries-per-step",
28
+ "--max-steps-per-turn",
29
+ "--mcp-config",
30
+ "--mcp-config-file",
31
+ "--model",
32
+ "--skills-dir",
33
+ "-m",
34
+ ]);
35
+ const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
36
+ "--afk",
37
+ "--auto-approve",
38
+ "--debug",
39
+ "--no-thinking",
40
+ "--plan",
41
+ "--thinking",
42
+ "--verbose",
43
+ "--yes",
44
+ "--yolo",
45
+ "-y",
46
+ ]);
47
+ // Flags owned by the adapter because BotCord depends on Kimi's non-interactive
48
+ // stream-json contract, cwd isolation, prompt placement, and session routing.
49
+ const KIMI_ADAPTER_OWNED_FLAGS = new Set([
50
+ "--acp",
51
+ "--command",
52
+ "--continue",
53
+ "--final-message-only",
54
+ "--help",
55
+ "--input-format",
56
+ "--output-format",
57
+ "--print",
58
+ "--prompt",
59
+ "--quiet",
60
+ "--resume",
61
+ "--session",
62
+ "--version",
63
+ "--wire",
64
+ "--work-dir",
65
+ "-C",
66
+ "-S",
67
+ "-V",
68
+ "-c",
69
+ "-h",
70
+ "-p",
71
+ "-r",
72
+ "-w",
73
+ ]);
74
+ function flagName(arg) {
75
+ if (!arg.startsWith("-"))
76
+ return arg;
77
+ const eq = arg.indexOf("=");
78
+ return eq === -1 ? arg : arg.slice(0, eq);
79
+ }
80
+ function nextValue(args, index) {
81
+ const next = args[index + 1];
82
+ if (typeof next !== "string")
83
+ return undefined;
84
+ if (!next.startsWith("-"))
85
+ return next;
86
+ return /^-\d/.test(next) ? next : undefined;
87
+ }
88
+ function sanitizeKimiExtraArgs(extraArgs) {
89
+ if (!extraArgs?.length)
90
+ return [];
91
+ const out = [];
92
+ for (let i = 0; i < extraArgs.length; i += 1) {
93
+ const arg = extraArgs[i];
94
+ const name = flagName(arg);
95
+ if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
96
+ if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined)
97
+ i += 1;
98
+ continue;
99
+ }
100
+ if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
101
+ if (arg.includes("=")) {
102
+ out.push(arg);
103
+ continue;
104
+ }
105
+ const value = nextValue(extraArgs, i);
106
+ if (value !== undefined) {
107
+ out.push(arg, value);
108
+ i += 1;
109
+ }
110
+ continue;
111
+ }
112
+ if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
113
+ out.push(arg);
114
+ continue;
115
+ }
116
+ if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
117
+ i += 1;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /** Resolve the Kimi CLI executable on PATH. */
123
+ export function resolveKimiCommand(deps = {}) {
124
+ return resolveCommandOnPath("kimi", deps);
125
+ }
126
+ /** Probe whether the Kimi CLI is installed and report its version. */
127
+ export function probeKimi(deps = {}) {
128
+ const command = resolveKimiCommand(deps);
129
+ if (!command)
130
+ return { available: false };
131
+ return {
132
+ available: true,
133
+ path: command,
134
+ version: readCommandVersion(command, [], deps) ?? undefined,
135
+ };
136
+ }
137
+ /**
138
+ * Kimi CLI adapter — spawns:
139
+ *
140
+ * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
141
+ *
142
+ * `--session <sid>` resumes an existing session or creates a new session with
143
+ * that id, so the adapter generates a UUID on first turn and persists it for
144
+ * later turns. Kimi does not expose a Codex-style per-invocation AGENTS.md
145
+ * carrier, so dynamic `systemContext` is sent as a system-reminder prefix on
146
+ * the user prompt.
147
+ */
148
+ export class KimiAdapter extends NdjsonStreamAdapter {
149
+ id = "kimi-cli";
150
+ explicitBinary;
151
+ resolvedBinary = null;
152
+ constructor(opts) {
153
+ super();
154
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_KIMI_CLI_BIN;
155
+ }
156
+ probe() {
157
+ return probeKimi();
158
+ }
159
+ async run(opts) {
160
+ if (opts.sessionId && !isValidKimiSessionId(opts.sessionId)) {
161
+ return { text: "", newSessionId: "", error: invalidKimiSessionIdError() };
162
+ }
163
+ const sessionId = opts.sessionId || randomUUID();
164
+ return super.run({ ...opts, sessionId });
165
+ }
166
+ resolveBinary() {
167
+ if (this.explicitBinary)
168
+ return this.explicitBinary;
169
+ if (this.resolvedBinary)
170
+ return this.resolvedBinary;
171
+ this.resolvedBinary = resolveKimiCommand() ?? "kimi";
172
+ return this.resolvedBinary;
173
+ }
174
+ buildArgs(opts) {
175
+ const sessionId = opts.sessionId || randomUUID();
176
+ if (!isValidKimiSessionId(sessionId))
177
+ throw new Error(invalidKimiSessionIdError());
178
+ const args = [
179
+ "--work-dir",
180
+ opts.cwd,
181
+ "--print",
182
+ "--output-format",
183
+ "stream-json",
184
+ "--session",
185
+ sessionId,
186
+ "--afk",
187
+ ];
188
+ args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
189
+ args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
190
+ return args;
191
+ }
192
+ spawnEnv(opts) {
193
+ const cliEnv = buildCliEnv({
194
+ hubUrl: opts.hubUrl,
195
+ accountId: opts.accountId,
196
+ basePath: process.env.PATH,
197
+ });
198
+ return {
199
+ ...process.env,
200
+ ...cliEnv,
201
+ FORCE_COLOR: "0",
202
+ NO_COLOR: "1",
203
+ };
204
+ }
205
+ handleEvent(raw, ctx) {
206
+ const obj = raw;
207
+ const status = kimiStatusEvent(obj);
208
+ if (status)
209
+ ctx.emitStatus(status);
210
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
211
+ const sessionId = kimiSessionId(obj);
212
+ if (sessionId)
213
+ ctx.state.newSessionId = sessionId;
214
+ if (obj.role === "assistant") {
215
+ const text = extractText(obj.content);
216
+ if (text) {
217
+ ctx.appendAssistantText(text);
218
+ ctx.state.finalText = text;
219
+ }
220
+ return;
221
+ }
222
+ const err = kimiErrorText(obj);
223
+ if (err)
224
+ ctx.state.errorText = err;
225
+ }
226
+ }
227
+ function promptWithSystemContext(text, systemContext) {
228
+ if (!systemContext)
229
+ return text;
230
+ return `<system-reminder>\n${systemContext}\n</system-reminder>\n\n${text}`;
231
+ }
232
+ function extractText(content) {
233
+ if (typeof content === "string")
234
+ return content;
235
+ if (!Array.isArray(content))
236
+ return "";
237
+ return content
238
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
239
+ .map((part) => part.text)
240
+ .join("");
241
+ }
242
+ function hasThinking(content) {
243
+ return Array.isArray(content)
244
+ ? content.some((part) => part?.type === "think" && typeof part.think === "string" && part.think)
245
+ : false;
246
+ }
247
+ function firstToolName(toolCalls) {
248
+ const name = toolCalls?.find((t) => typeof t.function?.name === "string")?.function?.name;
249
+ return name || "tool";
250
+ }
251
+ function kimiSessionId(obj) {
252
+ return typeof obj.session_id === "string" && obj.session_id ? obj.session_id : undefined;
253
+ }
254
+ function kimiErrorText(obj) {
255
+ if (typeof obj.error === "string" && obj.error)
256
+ return obj.error;
257
+ if (obj.error && typeof obj.error === "object") {
258
+ const message = obj.error.message;
259
+ if (typeof message === "string" && message)
260
+ return message;
261
+ }
262
+ if (obj.type === "error" && typeof obj.message === "string" && obj.message) {
263
+ return obj.message;
264
+ }
265
+ if (obj.severity === "error") {
266
+ return [obj.title, obj.body].filter(Boolean).join(": ") || "kimi-cli error";
267
+ }
268
+ return undefined;
269
+ }
270
+ function kimiStatusEvent(obj) {
271
+ if (obj.role === "assistant" && hasThinking(obj.content)) {
272
+ return { kind: "thinking", phase: "started", label: "Thinking" };
273
+ }
274
+ if (obj.role === "assistant" && obj.tool_calls?.length) {
275
+ return { kind: "thinking", phase: "updated", label: firstToolName(obj.tool_calls) };
276
+ }
277
+ if (obj.role === "assistant" && extractText(obj.content)) {
278
+ return { kind: "thinking", phase: "stopped" };
279
+ }
280
+ if (obj.role === "tool") {
281
+ return { kind: "thinking", phase: "updated", label: "Tool result" };
282
+ }
283
+ return undefined;
284
+ }
285
+ function normalizeBlock(obj, seq) {
286
+ let kind = "other";
287
+ if (obj.role === "assistant") {
288
+ if (obj.tool_calls?.length)
289
+ kind = "tool_use";
290
+ else if (extractText(obj.content))
291
+ kind = "assistant_text";
292
+ else if (hasThinking(obj.content))
293
+ kind = "other";
294
+ }
295
+ else if (obj.role === "tool") {
296
+ kind = "tool_result";
297
+ }
298
+ else if (obj.file_path && typeof obj.content === "string") {
299
+ kind = "other";
300
+ }
301
+ else if (obj.category || obj.severity) {
302
+ kind = "system";
303
+ }
304
+ return { raw: obj, kind, seq };
305
+ }
@@ -33,6 +33,10 @@ export interface RuntimeModule {
33
33
  export declare const claudeCodeModule: RuntimeModule;
34
34
  /** Built-in runtime module entry for Codex. */
35
35
  export declare const codexModule: RuntimeModule;
36
+ /** Built-in runtime module entry for DeepSeek TUI. */
37
+ export declare const deepseekTuiModule: RuntimeModule;
38
+ /** Built-in runtime module entry for Kimi CLI. */
39
+ export declare const kimiModule: RuntimeModule;
36
40
  /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
37
41
  export declare const hermesAgentModule: RuntimeModule;
38
42
  /** Built-in runtime module entry for Gemini (probe-only stub). */
@@ -1,7 +1,9 @@
1
1
  import { ClaudeCodeAdapter, probeClaude } from "./claude-code.js";
2
2
  import { CodexAdapter, probeCodex } from "./codex.js";
3
+ import { DeepseekTuiAdapter, probeDeepseekTui } from "./deepseek-tui.js";
3
4
  import { GeminiAdapter, probeGemini } from "./gemini.js";
4
5
  import { HermesAgentAdapter, probeHermesAgent } from "./hermes-agent.js";
6
+ import { KimiAdapter, probeKimi } from "./kimi.js";
5
7
  import { OpenclawAcpAdapter, probeOpenclaw } from "./openclaw-acp.js";
6
8
  /** Built-in runtime module entry for Claude Code. */
7
9
  export const claudeCodeModule = {
@@ -20,6 +22,25 @@ export const codexModule = {
20
22
  probe: () => probeCodex(),
21
23
  create: () => new CodexAdapter(),
22
24
  };
25
+ /** Built-in runtime module entry for DeepSeek TUI. */
26
+ export const deepseekTuiModule = {
27
+ id: "deepseek-tui",
28
+ displayName: "DeepSeek TUI",
29
+ binary: "deepseek",
30
+ envVar: "BOTCORD_DEEPSEEK_TUI_BIN",
31
+ probe: () => probeDeepseekTui(),
32
+ create: () => new DeepseekTuiAdapter(),
33
+ installHint: "Install DeepSeek TUI and ensure the `deepseek` dispatcher is on PATH, or set BOTCORD_DEEPSEEK_TUI_BIN.",
34
+ };
35
+ /** Built-in runtime module entry for Kimi CLI. */
36
+ export const kimiModule = {
37
+ id: "kimi-cli",
38
+ displayName: "Kimi CLI",
39
+ binary: "kimi",
40
+ envVar: "BOTCORD_KIMI_CLI_BIN",
41
+ probe: () => probeKimi(),
42
+ create: () => new KimiAdapter(),
43
+ };
23
44
  /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
24
45
  export const hermesAgentModule = {
25
46
  id: "hermes-agent",
@@ -57,6 +78,8 @@ export const openclawAcpModule = {
57
78
  export const RUNTIME_MODULES = [
58
79
  claudeCodeModule,
59
80
  codexModule,
81
+ deepseekTuiModule,
82
+ kimiModule,
60
83
  hermesAgentModule,
61
84
  geminiModule,
62
85
  openclawAcpModule,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -618,6 +618,92 @@ describe("createBotCordChannel — streamBlock()", () => {
618
618
  }
619
619
  });
620
620
 
621
+ it("normalizes DeepSeek message.delta assistant text", async () => {
622
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
623
+ const realFetch = globalThis.fetch;
624
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
625
+ try {
626
+ const client = makeClient({
627
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
628
+ });
629
+ const channel = createBotCordChannel({
630
+ id: "botcord-main",
631
+ accountId: "ag_self",
632
+ agentId: "ag_self",
633
+ client,
634
+ hubBaseUrl: "https://hub.example.com",
635
+ });
636
+ await channel.streamBlock!({
637
+ traceId: "m_trace",
638
+ accountId: "ag_self",
639
+ conversationId: "rm_oc_1",
640
+ block: {
641
+ kind: "assistant_text",
642
+ seq: 4,
643
+ raw: {
644
+ event: "message.delta",
645
+ payload: { thread_id: "thr_1", turn_id: "turn_1", content: "hello " },
646
+ },
647
+ },
648
+ log: silentLog,
649
+ });
650
+ const [, init] = fetchSpy.mock.calls[0];
651
+ const body = JSON.parse(init.body as string);
652
+ expect(body.block).toEqual({
653
+ kind: "assistant",
654
+ seq: 4,
655
+ payload: { text: "hello " },
656
+ });
657
+ } finally {
658
+ globalThis.fetch = realFetch;
659
+ }
660
+ });
661
+
662
+ it("normalizes DeepSeek item.delta assistant text", async () => {
663
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
664
+ const realFetch = globalThis.fetch;
665
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
666
+ try {
667
+ const client = makeClient({
668
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
669
+ });
670
+ const channel = createBotCordChannel({
671
+ id: "botcord-main",
672
+ accountId: "ag_self",
673
+ agentId: "ag_self",
674
+ client,
675
+ hubBaseUrl: "https://hub.example.com",
676
+ });
677
+ await channel.streamBlock!({
678
+ traceId: "m_trace",
679
+ accountId: "ag_self",
680
+ conversationId: "rm_oc_1",
681
+ block: {
682
+ kind: "assistant_text",
683
+ seq: 5,
684
+ raw: {
685
+ event: "item.delta",
686
+ payload: {
687
+ thread_id: "thr_1",
688
+ turn_id: "turn_1",
689
+ payload: { kind: "agent_message", delta: "deepseek" },
690
+ },
691
+ },
692
+ },
693
+ log: silentLog,
694
+ });
695
+ const [, init] = fetchSpy.mock.calls[0];
696
+ const body = JSON.parse(init.body as string);
697
+ expect(body.block).toEqual({
698
+ kind: "assistant",
699
+ seq: 5,
700
+ payload: { text: "deepseek" },
701
+ });
702
+ } finally {
703
+ globalThis.fetch = realFetch;
704
+ }
705
+ });
706
+
621
707
  it("normalizes a thinking block with phase/label/source payload", async () => {
622
708
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
623
709
  const realFetch = globalThis.fetch;
@@ -0,0 +1,212 @@
1
+ import { afterAll, describe, expect, it } from "vitest";
2
+ import http, { type ServerResponse } from "node:http";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { DeepseekTuiAdapter } from "../runtimes/deepseek-tui.js";
7
+
8
+ const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-deepseek-tui-"));
9
+
10
+ afterAll(() => {
11
+ rmSync(tmpRoot, { recursive: true, force: true });
12
+ });
13
+
14
+ function sse(event: string, data: unknown): string {
15
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
16
+ }
17
+
18
+ async function startMockDeepseekServer(opts?: {
19
+ token?: string;
20
+ threadId?: string;
21
+ turnId?: string;
22
+ events?: Array<{ event: string; data: unknown }>;
23
+ }) {
24
+ const token = opts?.token ?? "test-token";
25
+ const threadId = opts?.threadId ?? "thr_test";
26
+ const turnId = opts?.turnId ?? "turn_test";
27
+ const events =
28
+ opts?.events ??
29
+ [
30
+ { event: "turn.started", data: { thread_id: threadId, turn_id: turnId } },
31
+ { event: "tool.started", data: { id: "tool_1", name: "shell", input: { command: "pwd" } } },
32
+ { event: "tool.completed", data: { id: "tool_1", success: true, output: "/tmp" } },
33
+ { event: "message.delta", data: { thread_id: threadId, turn_id: turnId, content: "hello " } },
34
+ { event: "message.delta", data: { thread_id: threadId, turn_id: turnId, content: "deepseek" } },
35
+ { event: "turn.completed", data: { thread_id: threadId, turn_id: turnId, usage: {} } },
36
+ ];
37
+
38
+ const calls: Array<{ method: string; url: string; body?: any; auth?: string }> = [];
39
+ let eventRes: ServerResponse | null = null;
40
+
41
+ const server = http.createServer((req, res) => {
42
+ const chunks: Buffer[] = [];
43
+ req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
44
+ req.on("end", () => {
45
+ const rawBody = Buffer.concat(chunks).toString("utf8");
46
+ const body = rawBody ? JSON.parse(rawBody) : undefined;
47
+ calls.push({
48
+ method: req.method ?? "",
49
+ url: req.url ?? "",
50
+ body,
51
+ auth: req.headers.authorization,
52
+ });
53
+
54
+ if (req.url === "/health") {
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(JSON.stringify({ status: "ok" }));
57
+ return;
58
+ }
59
+ if (req.headers.authorization !== `Bearer ${token}`) {
60
+ res.writeHead(401, { "content-type": "application/json" });
61
+ res.end(JSON.stringify({ error: "unauthorized" }));
62
+ return;
63
+ }
64
+ if (req.method === "POST" && req.url === "/v1/threads") {
65
+ res.writeHead(201, { "content-type": "application/json" });
66
+ res.end(JSON.stringify({ id: threadId }));
67
+ return;
68
+ }
69
+ if (req.method === "PATCH" && req.url === `/v1/threads/${threadId}`) {
70
+ res.writeHead(200, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ id: threadId }));
72
+ return;
73
+ }
74
+ if (req.method === "GET" && req.url === `/v1/threads/${threadId}/events?since_seq=0`) {
75
+ res.writeHead(200, {
76
+ "content-type": "text/event-stream",
77
+ "cache-control": "no-cache",
78
+ connection: "keep-alive",
79
+ });
80
+ eventRes = res;
81
+ return;
82
+ }
83
+ if (req.method === "POST" && req.url === `/v1/threads/${threadId}/turns`) {
84
+ res.writeHead(201, { "content-type": "application/json" });
85
+ res.end(JSON.stringify({ thread: { id: threadId }, turn: { id: turnId } }));
86
+ setTimeout(() => {
87
+ for (const ev of events) eventRes?.write(sse(ev.event, ev.data));
88
+ eventRes?.end();
89
+ }, 5);
90
+ return;
91
+ }
92
+ res.writeHead(404, { "content-type": "application/json" });
93
+ res.end(JSON.stringify({ error: "not found" }));
94
+ });
95
+ });
96
+
97
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
98
+ const addr = server.address();
99
+ if (!addr || typeof addr !== "object") throw new Error("server did not bind");
100
+ return {
101
+ baseUrl: `http://127.0.0.1:${addr.port}`,
102
+ token,
103
+ threadId,
104
+ calls,
105
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
106
+ };
107
+ }
108
+
109
+ function runAdapter(serverUrl: string, authToken: string, sessionId: string | null = null) {
110
+ const adapter = new DeepseekTuiAdapter({ serverUrl, authToken });
111
+ const ctrl = new AbortController();
112
+ const blocks: string[] = [];
113
+ const status: Array<{ phase: string; label?: string }> = [];
114
+ const result = adapter.run({
115
+ text: "hi",
116
+ sessionId,
117
+ accountId: "ag_deepseek",
118
+ cwd: tmpRoot,
119
+ signal: ctrl.signal,
120
+ trustLevel: "owner",
121
+ systemContext: "runtime memory",
122
+ onBlock: (b) => blocks.push(b.kind),
123
+ onStatus: (e) => {
124
+ if (e.kind === "thinking") status.push({ phase: e.phase, label: e.label });
125
+ },
126
+ });
127
+ return { result, blocks, status };
128
+ }
129
+
130
+ describe("DeepseekTuiAdapter", () => {
131
+ it("creates a thread, starts a turn, parses SSE assistant text, and emits tool blocks", async () => {
132
+ const server = await startMockDeepseekServer();
133
+ try {
134
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
135
+ const res = await result;
136
+ expect(res).toEqual({ text: "hello deepseek", newSessionId: server.threadId });
137
+ expect(blocks).toContain("tool_use");
138
+ expect(blocks).toContain("tool_result");
139
+ expect(blocks).toContain("assistant_text");
140
+ expect(status).toContainEqual({ phase: "started", label: "Thinking" });
141
+ expect(status).toContainEqual({ phase: "updated", label: "shell" });
142
+ expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
143
+ expect(server.calls.find((c) => c.method === "POST" && c.url === "/v1/threads")?.body).toMatchObject({
144
+ workspace: tmpRoot,
145
+ system_prompt: "runtime memory",
146
+ auto_approve: true,
147
+ });
148
+ } finally {
149
+ await server.close();
150
+ }
151
+ });
152
+
153
+ it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
154
+ const server = await startMockDeepseekServer({ threadId: "thr_existing" });
155
+ try {
156
+ const { result } = runAdapter(server.baseUrl, server.token, "thr_existing");
157
+ const res = await result;
158
+ expect(res.newSessionId).toBe("thr_existing");
159
+ expect(server.calls.some((c) => c.method === "POST" && c.url === "/v1/threads")).toBe(false);
160
+ const patch = server.calls.find((c) => c.method === "PATCH");
161
+ expect(patch?.url).toBe("/v1/threads/thr_existing");
162
+ expect(patch?.body).toEqual({ system_prompt: "runtime memory" });
163
+ } finally {
164
+ await server.close();
165
+ }
166
+ });
167
+
168
+ it("clears stale session ids when DeepSeek reports the thread missing", async () => {
169
+ const server = await startMockDeepseekServer({ threadId: "thr_other" });
170
+ try {
171
+ const adapter = new DeepseekTuiAdapter({ serverUrl: server.baseUrl, authToken: server.token });
172
+ const ctrl = new AbortController();
173
+ const res = await adapter.run({
174
+ text: "hi",
175
+ sessionId: "thr_missing",
176
+ accountId: "ag_deepseek",
177
+ cwd: tmpRoot,
178
+ signal: ctrl.signal,
179
+ trustLevel: "owner",
180
+ });
181
+ expect(res.newSessionId).toBe("");
182
+ expect(res.error).toMatch(/HTTP 404/);
183
+ } finally {
184
+ await server.close();
185
+ }
186
+ });
187
+
188
+ it("returns a runtime error when DeepSeek completes the turn as failed", async () => {
189
+ const server = await startMockDeepseekServer({
190
+ events: [
191
+ { event: "turn.started", data: { thread_id: "thr_test", turn_id: "turn_test" } },
192
+ {
193
+ event: "turn.completed",
194
+ data: {
195
+ thread_id: "thr_test",
196
+ turn_id: "turn_test",
197
+ payload: { turn: { status: "failed", error: "missing api key" } },
198
+ },
199
+ },
200
+ ],
201
+ });
202
+ try {
203
+ const { result } = runAdapter(server.baseUrl, server.token);
204
+ const res = await result;
205
+ expect(res.text).toBe("");
206
+ expect(res.newSessionId).toBe("thr_test");
207
+ expect(res.error).toBe("missing api key");
208
+ } finally {
209
+ await server.close();
210
+ }
211
+ });
212
+ });