@botcord/daemon 0.2.49 → 0.2.50
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/dist/gateway/dispatcher.js +31 -6
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/runtimes/deepseek-tui.d.ts +44 -0
- package/dist/gateway/runtimes/deepseek-tui.js +560 -0
- package/dist/gateway/runtimes/kimi.d.ts +32 -0
- package/dist/gateway/runtimes/kimi.js +204 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +23 -0
- package/package.json +1 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +212 -0
- package/src/gateway/__tests__/dispatcher.test.ts +53 -3
- package/src/gateway/__tests__/kimi-adapter.test.ts +174 -0
- package/src/gateway/dispatcher.ts +31 -6
- package/src/gateway/index.ts +6 -0
- package/src/gateway/runtimes/deepseek-tui.ts +640 -0
- package/src/gateway/runtimes/kimi.ts +245 -0
- package/src/gateway/runtimes/registry.ts +26 -0
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
/** Resolve the Kimi CLI executable on PATH. */
|
|
21
|
+
export function resolveKimiCommand(deps = {}) {
|
|
22
|
+
return resolveCommandOnPath("kimi", deps);
|
|
23
|
+
}
|
|
24
|
+
/** Probe whether the Kimi CLI is installed and report its version. */
|
|
25
|
+
export function probeKimi(deps = {}) {
|
|
26
|
+
const command = resolveKimiCommand(deps);
|
|
27
|
+
if (!command)
|
|
28
|
+
return { available: false };
|
|
29
|
+
return {
|
|
30
|
+
available: true,
|
|
31
|
+
path: command,
|
|
32
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Kimi CLI adapter — spawns:
|
|
37
|
+
*
|
|
38
|
+
* kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
|
|
39
|
+
*
|
|
40
|
+
* `--session <sid>` resumes an existing session or creates a new session with
|
|
41
|
+
* that id, so the adapter generates a UUID on first turn and persists it for
|
|
42
|
+
* later turns. Kimi does not expose a Codex-style per-invocation AGENTS.md
|
|
43
|
+
* carrier, so dynamic `systemContext` is sent as a system-reminder prefix on
|
|
44
|
+
* the user prompt.
|
|
45
|
+
*/
|
|
46
|
+
export class KimiAdapter extends NdjsonStreamAdapter {
|
|
47
|
+
id = "kimi-cli";
|
|
48
|
+
explicitBinary;
|
|
49
|
+
resolvedBinary = null;
|
|
50
|
+
constructor(opts) {
|
|
51
|
+
super();
|
|
52
|
+
this.explicitBinary = opts?.binary ?? process.env.BOTCORD_KIMI_CLI_BIN;
|
|
53
|
+
}
|
|
54
|
+
probe() {
|
|
55
|
+
return probeKimi();
|
|
56
|
+
}
|
|
57
|
+
async run(opts) {
|
|
58
|
+
if (opts.sessionId && !isValidKimiSessionId(opts.sessionId)) {
|
|
59
|
+
return { text: "", newSessionId: "", error: invalidKimiSessionIdError() };
|
|
60
|
+
}
|
|
61
|
+
const sessionId = opts.sessionId || randomUUID();
|
|
62
|
+
return super.run({ ...opts, sessionId });
|
|
63
|
+
}
|
|
64
|
+
resolveBinary() {
|
|
65
|
+
if (this.explicitBinary)
|
|
66
|
+
return this.explicitBinary;
|
|
67
|
+
if (this.resolvedBinary)
|
|
68
|
+
return this.resolvedBinary;
|
|
69
|
+
this.resolvedBinary = resolveKimiCommand() ?? "kimi";
|
|
70
|
+
return this.resolvedBinary;
|
|
71
|
+
}
|
|
72
|
+
buildArgs(opts) {
|
|
73
|
+
const sessionId = opts.sessionId || randomUUID();
|
|
74
|
+
if (!isValidKimiSessionId(sessionId))
|
|
75
|
+
throw new Error(invalidKimiSessionIdError());
|
|
76
|
+
const args = [
|
|
77
|
+
"--work-dir",
|
|
78
|
+
opts.cwd,
|
|
79
|
+
"--print",
|
|
80
|
+
"--output-format",
|
|
81
|
+
"stream-json",
|
|
82
|
+
"--session",
|
|
83
|
+
sessionId,
|
|
84
|
+
"--afk",
|
|
85
|
+
];
|
|
86
|
+
if (opts.extraArgs?.length)
|
|
87
|
+
args.push(...opts.extraArgs);
|
|
88
|
+
args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
spawnEnv(opts) {
|
|
92
|
+
const cliEnv = buildCliEnv({
|
|
93
|
+
hubUrl: opts.hubUrl,
|
|
94
|
+
accountId: opts.accountId,
|
|
95
|
+
basePath: process.env.PATH,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
...process.env,
|
|
99
|
+
...cliEnv,
|
|
100
|
+
FORCE_COLOR: "0",
|
|
101
|
+
NO_COLOR: "1",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
handleEvent(raw, ctx) {
|
|
105
|
+
const obj = raw;
|
|
106
|
+
const status = kimiStatusEvent(obj);
|
|
107
|
+
if (status)
|
|
108
|
+
ctx.emitStatus(status);
|
|
109
|
+
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
110
|
+
const sessionId = kimiSessionId(obj);
|
|
111
|
+
if (sessionId)
|
|
112
|
+
ctx.state.newSessionId = sessionId;
|
|
113
|
+
if (obj.role === "assistant") {
|
|
114
|
+
const text = extractText(obj.content);
|
|
115
|
+
if (text) {
|
|
116
|
+
ctx.appendAssistantText(text);
|
|
117
|
+
ctx.state.finalText = text;
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const err = kimiErrorText(obj);
|
|
122
|
+
if (err)
|
|
123
|
+
ctx.state.errorText = err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function promptWithSystemContext(text, systemContext) {
|
|
127
|
+
if (!systemContext)
|
|
128
|
+
return text;
|
|
129
|
+
return `<system-reminder>\n${systemContext}\n</system-reminder>\n\n${text}`;
|
|
130
|
+
}
|
|
131
|
+
function extractText(content) {
|
|
132
|
+
if (typeof content === "string")
|
|
133
|
+
return content;
|
|
134
|
+
if (!Array.isArray(content))
|
|
135
|
+
return "";
|
|
136
|
+
return content
|
|
137
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
138
|
+
.map((part) => part.text)
|
|
139
|
+
.join("");
|
|
140
|
+
}
|
|
141
|
+
function hasThinking(content) {
|
|
142
|
+
return Array.isArray(content)
|
|
143
|
+
? content.some((part) => part?.type === "think" && typeof part.think === "string" && part.think)
|
|
144
|
+
: false;
|
|
145
|
+
}
|
|
146
|
+
function firstToolName(toolCalls) {
|
|
147
|
+
const name = toolCalls?.find((t) => typeof t.function?.name === "string")?.function?.name;
|
|
148
|
+
return name || "tool";
|
|
149
|
+
}
|
|
150
|
+
function kimiSessionId(obj) {
|
|
151
|
+
return typeof obj.session_id === "string" && obj.session_id ? obj.session_id : undefined;
|
|
152
|
+
}
|
|
153
|
+
function kimiErrorText(obj) {
|
|
154
|
+
if (typeof obj.error === "string" && obj.error)
|
|
155
|
+
return obj.error;
|
|
156
|
+
if (obj.error && typeof obj.error === "object") {
|
|
157
|
+
const message = obj.error.message;
|
|
158
|
+
if (typeof message === "string" && message)
|
|
159
|
+
return message;
|
|
160
|
+
}
|
|
161
|
+
if (obj.type === "error" && typeof obj.message === "string" && obj.message) {
|
|
162
|
+
return obj.message;
|
|
163
|
+
}
|
|
164
|
+
if (obj.severity === "error") {
|
|
165
|
+
return [obj.title, obj.body].filter(Boolean).join(": ") || "kimi-cli error";
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
function kimiStatusEvent(obj) {
|
|
170
|
+
if (obj.role === "assistant" && hasThinking(obj.content)) {
|
|
171
|
+
return { kind: "thinking", phase: "started", label: "Thinking" };
|
|
172
|
+
}
|
|
173
|
+
if (obj.role === "assistant" && obj.tool_calls?.length) {
|
|
174
|
+
return { kind: "thinking", phase: "updated", label: firstToolName(obj.tool_calls) };
|
|
175
|
+
}
|
|
176
|
+
if (obj.role === "assistant" && extractText(obj.content)) {
|
|
177
|
+
return { kind: "thinking", phase: "stopped" };
|
|
178
|
+
}
|
|
179
|
+
if (obj.role === "tool") {
|
|
180
|
+
return { kind: "thinking", phase: "updated", label: "Tool result" };
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function normalizeBlock(obj, seq) {
|
|
185
|
+
let kind = "other";
|
|
186
|
+
if (obj.role === "assistant") {
|
|
187
|
+
if (obj.tool_calls?.length)
|
|
188
|
+
kind = "tool_use";
|
|
189
|
+
else if (extractText(obj.content))
|
|
190
|
+
kind = "assistant_text";
|
|
191
|
+
else if (hasThinking(obj.content))
|
|
192
|
+
kind = "other";
|
|
193
|
+
}
|
|
194
|
+
else if (obj.role === "tool") {
|
|
195
|
+
kind = "tool_result";
|
|
196
|
+
}
|
|
197
|
+
else if (obj.file_path && typeof obj.content === "string") {
|
|
198
|
+
kind = "other";
|
|
199
|
+
}
|
|
200
|
+
else if (obj.category || obj.severity) {
|
|
201
|
+
kind = "system";
|
|
202
|
+
}
|
|
203
|
+
return { raw: obj, kind, seq };
|
|
204
|
+
}
|
|
@@ -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
|
@@ -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
|
+
});
|
|
@@ -27,6 +27,7 @@ function silentLogger(): GatewayLogger {
|
|
|
27
27
|
|
|
28
28
|
interface FakeChannelOptions {
|
|
29
29
|
id?: string;
|
|
30
|
+
type?: string;
|
|
30
31
|
withStream?: boolean;
|
|
31
32
|
withTyping?: boolean;
|
|
32
33
|
sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
|
|
@@ -36,7 +37,7 @@ interface FakeChannelOptions {
|
|
|
36
37
|
|
|
37
38
|
class FakeChannel implements ChannelAdapter {
|
|
38
39
|
readonly id: string;
|
|
39
|
-
readonly type
|
|
40
|
+
readonly type: string;
|
|
40
41
|
readonly sends: ChannelSendContext[] = [];
|
|
41
42
|
readonly streams: ChannelStreamBlockContext[] = [];
|
|
42
43
|
readonly typings: ChannelTypingContext[] = [];
|
|
@@ -48,6 +49,7 @@ class FakeChannel implements ChannelAdapter {
|
|
|
48
49
|
|
|
49
50
|
constructor(opts: FakeChannelOptions = {}) {
|
|
50
51
|
this.id = opts.id ?? "botcord";
|
|
52
|
+
this.type = opts.type ?? "fake";
|
|
51
53
|
this.sendImpl = opts.sendImpl;
|
|
52
54
|
this.streamImpl = opts.streamImpl;
|
|
53
55
|
this.typingImpl = opts.typingImpl;
|
|
@@ -659,8 +661,21 @@ describe("Dispatcher", () => {
|
|
|
659
661
|
expect(channel.sends.length).toBe(1);
|
|
660
662
|
});
|
|
661
663
|
|
|
662
|
-
it("typing:
|
|
663
|
-
const channel = new FakeChannel();
|
|
664
|
+
it("typing: fires for non-BotCord channels even when streamable is false", async () => {
|
|
665
|
+
const channel = new FakeChannel({ id: "gw_provider", type: "telegram" });
|
|
666
|
+
const { dispatcher } = await scaffold({
|
|
667
|
+
channel,
|
|
668
|
+
runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await dispatcher.handle(
|
|
672
|
+
makeEnvelope({ channel: "gw_provider", trace: { id: "t1", streamable: false } }),
|
|
673
|
+
);
|
|
674
|
+
expect(channel.typings.length).toBe(1);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("typing: not fired for BotCord rooms when streamable is false", async () => {
|
|
678
|
+
const channel = new FakeChannel({ type: "botcord" });
|
|
664
679
|
const { dispatcher } = await scaffold({
|
|
665
680
|
channel,
|
|
666
681
|
runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
|
|
@@ -672,6 +687,41 @@ describe("Dispatcher", () => {
|
|
|
672
687
|
expect(channel.typings.length).toBe(0);
|
|
673
688
|
});
|
|
674
689
|
|
|
690
|
+
it("typing: refreshes while a provider turn is still running", async () => {
|
|
691
|
+
vi.useFakeTimers();
|
|
692
|
+
try {
|
|
693
|
+
const channel = new FakeChannel({ id: "gw_tg", type: "telegram" });
|
|
694
|
+
const runtime = new FakeRuntime({ delayMs: 8500, reply: "ok", newSessionId: "sid" });
|
|
695
|
+
const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
696
|
+
|
|
697
|
+
const pending = dispatcher.handle(
|
|
698
|
+
makeEnvelope({
|
|
699
|
+
channel: "gw_tg",
|
|
700
|
+
conversation: { id: "telegram:user:42", kind: "direct" },
|
|
701
|
+
trace: { id: "telegram:42:1", streamable: true },
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
706
|
+
expect(channel.typings.length).toBe(1);
|
|
707
|
+
|
|
708
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
709
|
+
expect(channel.typings.length).toBe(2);
|
|
710
|
+
|
|
711
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
712
|
+
expect(channel.typings.length).toBe(3);
|
|
713
|
+
|
|
714
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
715
|
+
await pending;
|
|
716
|
+
|
|
717
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
718
|
+
expect(channel.typings.length).toBe(3);
|
|
719
|
+
expect(channel.sends.length).toBe(1);
|
|
720
|
+
} finally {
|
|
721
|
+
vi.useRealTimers();
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
675
725
|
it("typing: not fired when channel has no typing capability", async () => {
|
|
676
726
|
const channel = new FakeChannel({ withTyping: false });
|
|
677
727
|
const { dispatcher } = await scaffold({
|