@botcord/daemon 0.2.48 → 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/daemon.js +4 -3
- package/dist/gateway/dispatcher.js +32 -7
- 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/dist/mention-scan.js +6 -2
- package/dist/skill-index.d.ts +17 -0
- package/dist/skill-index.js +177 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +20 -2
- package/dist/turn-text.js +31 -0
- package/package.json +1 -1
- package/src/__tests__/mention-scan.test.ts +16 -0
- package/src/__tests__/system-context.test.ts +28 -1
- package/src/__tests__/turn-text.test.ts +27 -0
- package/src/daemon.ts +4 -3
- 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 +32 -7
- 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
- package/src/mention-scan.ts +5 -1
- package/src/skill-index.ts +232 -0
- package/src/system-context.ts +25 -2
- package/src/turn-text.ts +42 -0
|
@@ -57,6 +57,33 @@ describe("composeBotCordUserTurn", () => {
|
|
|
57
57
|
expect(out).toContain("mentioned: true");
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
it("renders structured room context outside the human message body", () => {
|
|
61
|
+
const out = composeBotCordUserTurn(
|
|
62
|
+
makeMessage({
|
|
63
|
+
sender: { id: "hu_alice", name: "Alice", kind: "user" },
|
|
64
|
+
text: "@Harry(ag_973dfb9193eb) 今天的AI日报发一下呢",
|
|
65
|
+
conversation: { id: "rm_news", kind: "group", title: "AI News Daily Brief" },
|
|
66
|
+
raw: {
|
|
67
|
+
room_id: "rm_news",
|
|
68
|
+
room_name: "AI News Daily Brief",
|
|
69
|
+
room_member_count: 6,
|
|
70
|
+
room_member_names: ["Alice", "Harry"],
|
|
71
|
+
my_role: "member",
|
|
72
|
+
my_can_send: true,
|
|
73
|
+
room_rule: "Post concise daily summaries.",
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
const roomIdx = out.indexOf("[BotCord Room]");
|
|
78
|
+
const tagIdx = out.indexOf('<human-message sender="Alice" sender_kind="human">');
|
|
79
|
+
const closeIdx = out.indexOf("</human-message>");
|
|
80
|
+
expect(roomIdx).toBeGreaterThan(-1);
|
|
81
|
+
expect(roomIdx).toBeLessThan(tagIdx);
|
|
82
|
+
expect(out).toContain("[Room Rule] Post concise daily summaries.");
|
|
83
|
+
expect(out.slice(tagIdx, closeIdx)).not.toContain("[BotCord Room]");
|
|
84
|
+
expect(out.slice(tagIdx, closeIdx)).toContain("@Harry(ag_973dfb9193eb)");
|
|
85
|
+
});
|
|
86
|
+
|
|
60
87
|
it("emits the direct-chat hint (not the group hint) for DM conversations", () => {
|
|
61
88
|
const out = composeBotCordUserTurn(
|
|
62
89
|
makeMessage({
|
package/src/daemon.ts
CHANGED
|
@@ -48,10 +48,11 @@ import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
|
48
48
|
import { scanMention } from "./mention-scan.js";
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* Default hard cap for a single runtime turn. Long-running coding/research
|
|
52
|
+
* tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
|
|
53
|
+
* window before the dispatcher aborts the runtime.
|
|
53
54
|
*/
|
|
54
|
-
const DEFAULT_TURN_TIMEOUT_MS =
|
|
55
|
+
const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* Default cadence for writing `gateway.snapshot()` to disk. Override via
|
|
@@ -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({
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { KimiAdapter } from "../runtimes/kimi.js";
|
|
6
|
+
import { createRuntime, envVarForRuntime, listRuntimeIds } from "../runtimes/registry.js";
|
|
7
|
+
|
|
8
|
+
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-kimi-"));
|
|
9
|
+
|
|
10
|
+
function makeScript(name: string, body: string): string {
|
|
11
|
+
const p = path.join(tmpRoot, name);
|
|
12
|
+
writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
|
|
13
|
+
chmodSync(p, 0o755);
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function runAdapter(script: string, sessionId: string | null = null) {
|
|
22
|
+
const adapter = new KimiAdapter({ binary: script });
|
|
23
|
+
const ctrl = new AbortController();
|
|
24
|
+
return adapter.run({
|
|
25
|
+
text: "hi",
|
|
26
|
+
sessionId,
|
|
27
|
+
accountId: "ag_test",
|
|
28
|
+
cwd: tmpRoot,
|
|
29
|
+
signal: ctrl.signal,
|
|
30
|
+
trustLevel: "owner",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("KimiAdapter", () => {
|
|
35
|
+
it("is registered as a runnable runtime", () => {
|
|
36
|
+
expect(listRuntimeIds()).toContain("kimi-cli");
|
|
37
|
+
expect(envVarForRuntime("kimi-cli")).toBe("BOTCORD_KIMI_CLI_BIN");
|
|
38
|
+
expect(createRuntime("kimi-cli")).toBeInstanceOf(KimiAdapter);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("parses final assistant text and persists the generated --session id", async () => {
|
|
42
|
+
const script = makeScript(
|
|
43
|
+
"happy.js",
|
|
44
|
+
`
|
|
45
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:"hello from kimi"}) + "\\n");
|
|
46
|
+
`,
|
|
47
|
+
);
|
|
48
|
+
const res = await runAdapter(script);
|
|
49
|
+
expect(res.newSessionId).toMatch(/^[0-9a-f-]{36}$/);
|
|
50
|
+
expect(res.text).toBe("hello from kimi");
|
|
51
|
+
expect(res.error).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("passes an existing session id through --session", async () => {
|
|
55
|
+
const script = makeScript(
|
|
56
|
+
"resume-argv.js",
|
|
57
|
+
`
|
|
58
|
+
const argv = process.argv.slice(2);
|
|
59
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
|
|
60
|
+
`,
|
|
61
|
+
);
|
|
62
|
+
const res = await runAdapter(script, "sid-123");
|
|
63
|
+
const argv = JSON.parse(res.text) as string[];
|
|
64
|
+
const idx = argv.indexOf("--session");
|
|
65
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
66
|
+
expect(argv[idx + 1]).toBe("sid-123");
|
|
67
|
+
expect(argv).toContain("--print");
|
|
68
|
+
expect(argv).toContain("stream-json");
|
|
69
|
+
expect(argv).toContain("--afk");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects session ids that could be parsed as flags", async () => {
|
|
73
|
+
const script = makeScript(
|
|
74
|
+
"should-not-spawn.js",
|
|
75
|
+
`
|
|
76
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:"spawned"}) + "\\n");
|
|
77
|
+
`,
|
|
78
|
+
);
|
|
79
|
+
const res = await runAdapter(script, "--bad");
|
|
80
|
+
expect(res.newSessionId).toBe("");
|
|
81
|
+
expect(res.text).toBe("");
|
|
82
|
+
expect(res.error).toMatch(/invalid sessionId/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prefixes systemContext as a system-reminder in the prompt", async () => {
|
|
86
|
+
const script = makeScript(
|
|
87
|
+
"echo-prompt.js",
|
|
88
|
+
`
|
|
89
|
+
const argv = process.argv.slice(2);
|
|
90
|
+
const prompt = argv[argv.indexOf("--prompt") + 1];
|
|
91
|
+
process.stdout.write(JSON.stringify({role:"assistant", content:prompt}) + "\\n");
|
|
92
|
+
`,
|
|
93
|
+
);
|
|
94
|
+
const adapter = new KimiAdapter({ binary: script });
|
|
95
|
+
const ctrl = new AbortController();
|
|
96
|
+
const res = await adapter.run({
|
|
97
|
+
text: "do the thing",
|
|
98
|
+
sessionId: null,
|
|
99
|
+
accountId: "ag_test",
|
|
100
|
+
cwd: tmpRoot,
|
|
101
|
+
signal: ctrl.signal,
|
|
102
|
+
trustLevel: "owner",
|
|
103
|
+
systemContext: "MEMORY: remember X",
|
|
104
|
+
});
|
|
105
|
+
expect(res.text).toContain("<system-reminder>");
|
|
106
|
+
expect(res.text).toContain("MEMORY: remember X");
|
|
107
|
+
expect(res.text).toContain("do the thing");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("recognizes tool_use and tool_result blocks", async () => {
|
|
111
|
+
const script = makeScript(
|
|
112
|
+
"tools.js",
|
|
113
|
+
`
|
|
114
|
+
const lines = [
|
|
115
|
+
{role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"Bash", arguments:"{}"}}]},
|
|
116
|
+
{role:"tool", tool_call_id:"tc1", content:"ok"},
|
|
117
|
+
{role:"assistant", content:[{type:"text", text:"done"}]},
|
|
118
|
+
];
|
|
119
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
120
|
+
`,
|
|
121
|
+
);
|
|
122
|
+
const adapter = new KimiAdapter({ binary: script });
|
|
123
|
+
const ctrl = new AbortController();
|
|
124
|
+
const seen: string[] = [];
|
|
125
|
+
const res = await adapter.run({
|
|
126
|
+
text: "x",
|
|
127
|
+
sessionId: null,
|
|
128
|
+
accountId: "ag_test",
|
|
129
|
+
cwd: tmpRoot,
|
|
130
|
+
signal: ctrl.signal,
|
|
131
|
+
trustLevel: "owner",
|
|
132
|
+
onBlock: (b) => seen.push(b.kind),
|
|
133
|
+
});
|
|
134
|
+
expect(res.text).toBe("done");
|
|
135
|
+
expect(seen).toContain("tool_use");
|
|
136
|
+
expect(seen).toContain("tool_result");
|
|
137
|
+
expect(seen).toContain("assistant_text");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("emits thinking status for think, tool call, tool result, and final text", async () => {
|
|
141
|
+
const script = makeScript(
|
|
142
|
+
"thinkflow.js",
|
|
143
|
+
`
|
|
144
|
+
const lines = [
|
|
145
|
+
{role:"assistant", content:[{type:"think", think:"working"}]},
|
|
146
|
+
{role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"ReadFile", arguments:"{}"}}]},
|
|
147
|
+
{role:"tool", tool_call_id:"tc1", content:"ok"},
|
|
148
|
+
{role:"assistant", content:"done"},
|
|
149
|
+
];
|
|
150
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
151
|
+
`,
|
|
152
|
+
);
|
|
153
|
+
const adapter = new KimiAdapter({ binary: script });
|
|
154
|
+
const ctrl = new AbortController();
|
|
155
|
+
const status: Array<{ phase: string; label?: string }> = [];
|
|
156
|
+
await adapter.run({
|
|
157
|
+
text: "x",
|
|
158
|
+
sessionId: null,
|
|
159
|
+
accountId: "ag_test",
|
|
160
|
+
cwd: tmpRoot,
|
|
161
|
+
signal: ctrl.signal,
|
|
162
|
+
trustLevel: "owner",
|
|
163
|
+
onStatus: (e) => {
|
|
164
|
+
if (e.kind === "thinking") status.push({ phase: e.phase, label: e.label });
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(status).toEqual([
|
|
168
|
+
{ phase: "started", label: "Thinking" },
|
|
169
|
+
{ phase: "updated", label: "ReadFile" },
|
|
170
|
+
{ phase: "updated", label: "Tool result" },
|
|
171
|
+
{ phase: "stopped", label: undefined },
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
UserTurnBuilder,
|
|
29
29
|
} from "./types.js";
|
|
30
30
|
|
|
31
|
-
const DEFAULT_TURN_TIMEOUT_MS =
|
|
31
|
+
const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
|
|
@@ -55,6 +55,14 @@ const MAX_BATCH_BUFFER_CHARS = 16000;
|
|
|
55
55
|
*/
|
|
56
56
|
const TYPING_DEBOUNCE_MS = 2000;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Most provider typing APIs are short-lived one-shots. Telegram's
|
|
60
|
+
* `sendChatAction(typing)`, for example, must be refreshed while the runtime
|
|
61
|
+
* is still working or the visible typing indicator disappears before the
|
|
62
|
+
* reply lands.
|
|
63
|
+
*/
|
|
64
|
+
const TYPING_REFRESH_MS = 4000;
|
|
65
|
+
|
|
58
66
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
59
67
|
const TYPING_RECENCY_CAP = 1024;
|
|
60
68
|
|
|
@@ -804,7 +812,9 @@ export class Dispatcher {
|
|
|
804
812
|
const streamable = msg.trace?.streamable === true;
|
|
805
813
|
const traceId = msg.trace?.id;
|
|
806
814
|
const canType =
|
|
807
|
-
|
|
815
|
+
typeof traceId === "string" &&
|
|
816
|
+
typeof channel.typing === "function" &&
|
|
817
|
+
(streamable || !isBotCordChannel(channel));
|
|
808
818
|
const canStream =
|
|
809
819
|
streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
810
820
|
const recordBlock = (block: StreamBlock): void => {
|
|
@@ -824,7 +834,8 @@ export class Dispatcher {
|
|
|
824
834
|
// arrival), and `thinking` is auto-synthesized on the first non-assistant
|
|
825
835
|
// block so adapters that emit nothing-but-blocks still drive the
|
|
826
836
|
// "Thinking..." UI.
|
|
827
|
-
let
|
|
837
|
+
let typingLoopStarted = false;
|
|
838
|
+
let typingRefreshTimer: NodeJS.Timeout | null = null;
|
|
828
839
|
let thinkingActive = false;
|
|
829
840
|
/**
|
|
830
841
|
* Sticky: once we've forwarded any assistant_text to the wire, we stop
|
|
@@ -887,9 +898,8 @@ export class Dispatcher {
|
|
|
887
898
|
forwardBlockToChannel(synth);
|
|
888
899
|
};
|
|
889
900
|
|
|
890
|
-
const
|
|
891
|
-
if (!canType
|
|
892
|
-
typingFired = true;
|
|
901
|
+
const sendTypingPing = (): void => {
|
|
902
|
+
if (!canType) return;
|
|
893
903
|
const key = `${msg.accountId}:${msg.conversation.id}`;
|
|
894
904
|
const now = Date.now();
|
|
895
905
|
const last = this.recentTypingPings.get(key);
|
|
@@ -934,7 +944,21 @@ export class Dispatcher {
|
|
|
934
944
|
}
|
|
935
945
|
};
|
|
936
946
|
|
|
937
|
-
const
|
|
947
|
+
const fireTypingIfNeeded = (): void => {
|
|
948
|
+
if (!canType || typingLoopStarted) return;
|
|
949
|
+
typingLoopStarted = true;
|
|
950
|
+
sendTypingPing();
|
|
951
|
+
typingRefreshTimer = setInterval(sendTypingPing, TYPING_REFRESH_MS);
|
|
952
|
+
if (typeof typingRefreshTimer.unref === "function") typingRefreshTimer.unref();
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const stopTypingRefresh = (): void => {
|
|
956
|
+
if (!typingRefreshTimer) return;
|
|
957
|
+
clearInterval(typingRefreshTimer);
|
|
958
|
+
typingRefreshTimer = null;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const onStatus = canType || canStream
|
|
938
962
|
? (event: RuntimeStatusEvent) => {
|
|
939
963
|
// Drop runtime callbacks after this turn's controller aborts —
|
|
940
964
|
// NDJSON/ACP adapters keep parsing stdout until the child exits
|
|
@@ -1342,6 +1366,7 @@ export class Dispatcher {
|
|
|
1342
1366
|
blocks: slot.blocks,
|
|
1343
1367
|
});
|
|
1344
1368
|
} finally {
|
|
1369
|
+
stopTypingRefresh();
|
|
1345
1370
|
// Emit a final thinking.stopped on terminal paths so the frontend
|
|
1346
1371
|
// never sticks at "Thinking..." when no assistant_text ever landed
|
|
1347
1372
|
// (timeout, error, gated reply). Skipped on cancel-previous: the
|
package/src/gateway/index.ts
CHANGED
|
@@ -39,6 +39,12 @@ export {
|
|
|
39
39
|
resolveClaudeCommand,
|
|
40
40
|
} from "./runtimes/claude-code.js";
|
|
41
41
|
export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
|
|
42
|
+
export {
|
|
43
|
+
DeepseekTuiAdapter,
|
|
44
|
+
probeDeepseekTui,
|
|
45
|
+
resolveDeepseekCommand,
|
|
46
|
+
} from "./runtimes/deepseek-tui.js";
|
|
47
|
+
export { KimiAdapter, probeKimi, resolveKimiCommand } from "./runtimes/kimi.js";
|
|
42
48
|
export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
|
|
43
49
|
export {
|
|
44
50
|
NdjsonStreamAdapter,
|