@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.
@@ -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
+ });
@@ -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,