@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.
@@ -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 = "fake";
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: not fired when streamable is false", async () => {
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,258 @@
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, extraArgs?: string[]) {
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
+ extraArgs,
32
+ });
33
+ }
34
+
35
+ describe("KimiAdapter", () => {
36
+ it("is registered as a runnable runtime", () => {
37
+ expect(listRuntimeIds()).toContain("kimi-cli");
38
+ expect(envVarForRuntime("kimi-cli")).toBe("BOTCORD_KIMI_CLI_BIN");
39
+ expect(createRuntime("kimi-cli")).toBeInstanceOf(KimiAdapter);
40
+ });
41
+
42
+ it("parses final assistant text and persists the generated --session id", async () => {
43
+ const script = makeScript(
44
+ "happy.js",
45
+ `
46
+ process.stdout.write(JSON.stringify({role:"assistant", content:"hello from kimi"}) + "\\n");
47
+ `,
48
+ );
49
+ const res = await runAdapter(script);
50
+ expect(res.newSessionId).toMatch(/^[0-9a-f-]{36}$/);
51
+ expect(res.text).toBe("hello from kimi");
52
+ expect(res.error).toBeUndefined();
53
+ });
54
+
55
+ it("passes an existing session id through --session", async () => {
56
+ const script = makeScript(
57
+ "resume-argv.js",
58
+ `
59
+ const argv = process.argv.slice(2);
60
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
61
+ `,
62
+ );
63
+ const res = await runAdapter(script, "sid-123");
64
+ const argv = JSON.parse(res.text) as string[];
65
+ const idx = argv.indexOf("--session");
66
+ expect(idx).toBeGreaterThanOrEqual(0);
67
+ expect(argv[idx + 1]).toBe("sid-123");
68
+ expect(argv).toContain("--print");
69
+ expect(argv).toContain("stream-json");
70
+ expect(argv).toContain("--afk");
71
+ });
72
+
73
+ it("drops non-Kimi inherited extraArgs and their values", async () => {
74
+ const script = makeScript(
75
+ "filter-foreign-argv.js",
76
+ `
77
+ const argv = process.argv.slice(2);
78
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
79
+ `,
80
+ );
81
+ const res = await runAdapter(script, "sid-123", [
82
+ "--permission-mode",
83
+ "bypassPermissions",
84
+ "--model",
85
+ "kimi-k2",
86
+ ]);
87
+ const argv = JSON.parse(res.text) as string[];
88
+ expect(argv).not.toContain("--permission-mode");
89
+ expect(argv).not.toContain("bypassPermissions");
90
+ expect(argv).toContain("--model");
91
+ expect(argv[argv.indexOf("--model") + 1]).toBe("kimi-k2");
92
+ });
93
+
94
+ it("preserves Kimi value flags with negative numeric values", async () => {
95
+ const script = makeScript(
96
+ "negative-value-argv.js",
97
+ `
98
+ const argv = process.argv.slice(2);
99
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
100
+ `,
101
+ );
102
+ const res = await runAdapter(script, "sid-123", [
103
+ "--max-ralph-iterations",
104
+ "-1",
105
+ "--max-steps-per-turn=3",
106
+ ]);
107
+ const argv = JSON.parse(res.text) as string[];
108
+ expect(argv).toContain("--max-ralph-iterations");
109
+ expect(argv[argv.indexOf("--max-ralph-iterations") + 1]).toBe("-1");
110
+ expect(argv).toContain("--max-steps-per-turn=3");
111
+ });
112
+
113
+ it("drops incomplete Kimi value flags instead of passing invalid argv", async () => {
114
+ const script = makeScript(
115
+ "incomplete-value-argv.js",
116
+ `
117
+ const argv = process.argv.slice(2);
118
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
119
+ `,
120
+ );
121
+ const res = await runAdapter(script, "sid-123", ["--model", "--plan"]);
122
+ const argv = JSON.parse(res.text) as string[];
123
+ expect(argv).not.toContain("--model");
124
+ expect(argv).toContain("--plan");
125
+ });
126
+
127
+ it("does not let extraArgs override adapter-owned stream/session/prompt flags", async () => {
128
+ const script = makeScript(
129
+ "filter-owned-argv.js",
130
+ `
131
+ const argv = process.argv.slice(2);
132
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
133
+ `,
134
+ );
135
+ const res = await runAdapter(script, "real-session", [
136
+ "--output-format",
137
+ "text",
138
+ "--session",
139
+ "evil-session",
140
+ "--prompt",
141
+ "evil prompt",
142
+ "--plan",
143
+ ]);
144
+ const argv = JSON.parse(res.text) as string[];
145
+ expect(argv.filter((a) => a === "--output-format")).toHaveLength(1);
146
+ expect(argv[argv.indexOf("--output-format") + 1]).toBe("stream-json");
147
+ expect(argv.filter((a) => a === "--session")).toHaveLength(1);
148
+ expect(argv[argv.indexOf("--session") + 1]).toBe("real-session");
149
+ expect(argv.filter((a) => a === "--prompt")).toHaveLength(1);
150
+ expect(argv[argv.indexOf("--prompt") + 1]).toBe("hi");
151
+ expect(argv).toContain("--plan");
152
+ expect(argv).not.toContain("evil-session");
153
+ expect(argv).not.toContain("evil prompt");
154
+ });
155
+
156
+ it("rejects session ids that could be parsed as flags", async () => {
157
+ const script = makeScript(
158
+ "should-not-spawn.js",
159
+ `
160
+ process.stdout.write(JSON.stringify({role:"assistant", content:"spawned"}) + "\\n");
161
+ `,
162
+ );
163
+ const res = await runAdapter(script, "--bad");
164
+ expect(res.newSessionId).toBe("");
165
+ expect(res.text).toBe("");
166
+ expect(res.error).toMatch(/invalid sessionId/);
167
+ });
168
+
169
+ it("prefixes systemContext as a system-reminder in the prompt", async () => {
170
+ const script = makeScript(
171
+ "echo-prompt.js",
172
+ `
173
+ const argv = process.argv.slice(2);
174
+ const prompt = argv[argv.indexOf("--prompt") + 1];
175
+ process.stdout.write(JSON.stringify({role:"assistant", content:prompt}) + "\\n");
176
+ `,
177
+ );
178
+ const adapter = new KimiAdapter({ binary: script });
179
+ const ctrl = new AbortController();
180
+ const res = await adapter.run({
181
+ text: "do the thing",
182
+ sessionId: null,
183
+ accountId: "ag_test",
184
+ cwd: tmpRoot,
185
+ signal: ctrl.signal,
186
+ trustLevel: "owner",
187
+ systemContext: "MEMORY: remember X",
188
+ });
189
+ expect(res.text).toContain("<system-reminder>");
190
+ expect(res.text).toContain("MEMORY: remember X");
191
+ expect(res.text).toContain("do the thing");
192
+ });
193
+
194
+ it("recognizes tool_use and tool_result blocks", async () => {
195
+ const script = makeScript(
196
+ "tools.js",
197
+ `
198
+ const lines = [
199
+ {role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"Bash", arguments:"{}"}}]},
200
+ {role:"tool", tool_call_id:"tc1", content:"ok"},
201
+ {role:"assistant", content:[{type:"text", text:"done"}]},
202
+ ];
203
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
204
+ `,
205
+ );
206
+ const adapter = new KimiAdapter({ binary: script });
207
+ const ctrl = new AbortController();
208
+ const seen: string[] = [];
209
+ const res = await adapter.run({
210
+ text: "x",
211
+ sessionId: null,
212
+ accountId: "ag_test",
213
+ cwd: tmpRoot,
214
+ signal: ctrl.signal,
215
+ trustLevel: "owner",
216
+ onBlock: (b) => seen.push(b.kind),
217
+ });
218
+ expect(res.text).toBe("done");
219
+ expect(seen).toContain("tool_use");
220
+ expect(seen).toContain("tool_result");
221
+ expect(seen).toContain("assistant_text");
222
+ });
223
+
224
+ it("emits thinking status for think, tool call, tool result, and final text", async () => {
225
+ const script = makeScript(
226
+ "thinkflow.js",
227
+ `
228
+ const lines = [
229
+ {role:"assistant", content:[{type:"think", think:"working"}]},
230
+ {role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"ReadFile", arguments:"{}"}}]},
231
+ {role:"tool", tool_call_id:"tc1", content:"ok"},
232
+ {role:"assistant", content:"done"},
233
+ ];
234
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
235
+ `,
236
+ );
237
+ const adapter = new KimiAdapter({ binary: script });
238
+ const ctrl = new AbortController();
239
+ const status: Array<{ phase: string; label?: string }> = [];
240
+ await adapter.run({
241
+ text: "x",
242
+ sessionId: null,
243
+ accountId: "ag_test",
244
+ cwd: tmpRoot,
245
+ signal: ctrl.signal,
246
+ trustLevel: "owner",
247
+ onStatus: (e) => {
248
+ if (e.kind === "thinking") status.push({ phase: e.phase, label: e.label });
249
+ },
250
+ });
251
+ expect(status).toEqual([
252
+ { phase: "started", label: "Thinking" },
253
+ { phase: "updated", label: "ReadFile" },
254
+ { phase: "updated", label: "Tool result" },
255
+ { phase: "stopped", label: undefined },
256
+ ]);
257
+ });
258
+ });
@@ -907,12 +907,25 @@ function normalizeBlockForHub(
907
907
  if (kind === "assistant_text") {
908
908
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
909
909
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
910
+ // DeepSeek: {event:"message.delta", payload:{content}} or
911
+ // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
910
912
  let text = "";
911
913
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
912
914
  for (const c of contents) {
913
915
  if (c?.type === "text" && typeof c.text === "string") text += c.text;
914
916
  }
915
917
  if (!text && typeof raw?.item?.text === "string") text = raw.item.text;
918
+ if (!text && raw?.event === "message.delta" && typeof raw?.payload?.content === "string") {
919
+ text = raw.payload.content;
920
+ }
921
+ if (
922
+ !text &&
923
+ raw?.event === "item.delta" &&
924
+ raw?.payload?.payload?.kind === "agent_message" &&
925
+ typeof raw?.payload?.payload?.delta === "string"
926
+ ) {
927
+ text = raw.payload.payload.delta;
928
+ }
916
929
  return { kind: "assistant", seq, payload: { text } };
917
930
  }
918
931
 
@@ -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
- streamable && typeof traceId === "string" && typeof channel.typing === "function";
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 typingFired = false;
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 fireTypingIfNeeded = (): void => {
891
- if (!canType || typingFired) return;
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 onStatus = canStream
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
@@ -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,