@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.
- package/dist/gateway/channels/botcord.js +11 -0
- 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 +305 -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__/botcord-channel.test.ts +86 -0
- 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 +258 -0
- package/src/gateway/channels/botcord.ts +13 -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 +352 -0
- package/src/gateway/runtimes/registry.ts +26 -0
|
@@ -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,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
|
-
|
|
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,
|