@dobby.ai/dobby 0.1.0

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.
Files changed (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,128 @@
1
+ {
2
+ "extensions": {
3
+ "allowList": [
4
+ {
5
+ "package": "@dobby.ai/provider-pi",
6
+ "enabled": true
7
+ },
8
+ {
9
+ "package": "@dobby.ai/connector-discord",
10
+ "enabled": true
11
+ },
12
+ {
13
+ "package": "@dobby.ai/connector-feishu",
14
+ "enabled": false
15
+ },
16
+ {
17
+ "package": "@dobby.ai/provider-claude-cli",
18
+ "enabled": false
19
+ }
20
+ ]
21
+ },
22
+ "providers": {
23
+ "default": "pi.main",
24
+ "items": {
25
+ "pi.main": {
26
+ "type": "provider.pi",
27
+ "provider": "custom-openai",
28
+ "model": "example-model",
29
+ "thinkingLevel": "off",
30
+ "modelsFile": "./models.custom.json"
31
+ }
32
+ }
33
+ },
34
+ "connectors": {
35
+ "items": {
36
+ "discord.main": {
37
+ "type": "connector.discord",
38
+ "botName": "dobby-main",
39
+ "botToken": "REPLACE_WITH_DISCORD_BOT_TOKEN",
40
+ "reconnectStaleMs": 60000,
41
+ "reconnectCheckIntervalMs": 10000
42
+ }
43
+ }
44
+ },
45
+ "sandboxes": {
46
+ "default": "host.builtin",
47
+ "items": {}
48
+ },
49
+ "routes": {
50
+ "defaults": {
51
+ "provider": "pi.main",
52
+ "sandbox": "host.builtin",
53
+ "tools": "full",
54
+ "mentions": "required"
55
+ },
56
+ "items": {
57
+ "projectA": {
58
+ "projectRoot": "/Users/you/workspace/project-a"
59
+ }
60
+ }
61
+ },
62
+ "bindings": {
63
+ "items": {
64
+ "discord.main.projectA": {
65
+ "connector": "discord.main",
66
+ "source": {
67
+ "type": "channel",
68
+ "id": "YOUR_DISCORD_CHANNEL_ID_A"
69
+ },
70
+ "route": "projectA"
71
+ }
72
+ }
73
+ },
74
+ "_examples": {
75
+ "connectors": {
76
+ "items": {
77
+ "feishu.main": {
78
+ "type": "connector.feishu",
79
+ "appId": "cli_xxx",
80
+ "appSecret": "REPLACE_WITH_FEISHU_APP_SECRET",
81
+ "domain": "feishu",
82
+ "botName": "dobby-feishu",
83
+ "messageFormat": "card_markdown",
84
+ "replyMode": "direct",
85
+ "cardTitle": "Dobby",
86
+ "downloadAttachments": true
87
+ }
88
+ }
89
+ },
90
+ "providers": {
91
+ "items": {
92
+ "claude-cli.main": {
93
+ "type": "provider.claude-cli",
94
+ "model": "claude-sonnet-4-5",
95
+ "maxTurns": 20,
96
+ "command": "claude",
97
+ "commandArgs": [],
98
+ "authMode": "auto",
99
+ "permissionMode": "bypassPermissions",
100
+ "streamVerbose": true
101
+ }
102
+ }
103
+ },
104
+ "routes": {
105
+ "items": {
106
+ "projectA": {
107
+ "provider": "claude-cli.main"
108
+ }
109
+ }
110
+ },
111
+ "bindings": {
112
+ "items": {
113
+ "feishu.main.projectA": {
114
+ "connector": "feishu.main",
115
+ "source": {
116
+ "type": "chat",
117
+ "id": "oc_xxx"
118
+ },
119
+ "route": "projectA"
120
+ }
121
+ }
122
+ }
123
+ },
124
+ "data": {
125
+ "rootDir": "./data",
126
+ "dedupTtlMs": 604800000
127
+ }
128
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "providers": {
3
+ "custom-openai": {
4
+ "baseUrl": "https://api.example.com/v1",
5
+ "api": "openai-completions",
6
+ "apiKey": "CUSTOM_PROVIDER_AUTH_TOKEN",
7
+ "models": [
8
+ {
9
+ "id": "example-model",
10
+ "name": "example-model",
11
+ "reasoning": false,
12
+ "input": [
13
+ "text"
14
+ ],
15
+ "contextWindow": 128000,
16
+ "maxTokens": 8192,
17
+ "cost": {
18
+ "input": 0,
19
+ "output": 0,
20
+ "cacheRead": 0,
21
+ "cacheWrite": 0
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,341 @@
1
+ function truncate(text, max) {
2
+ if (max === undefined)
3
+ return text;
4
+ if (max <= 0)
5
+ return "";
6
+ if (text.length <= max)
7
+ return text;
8
+ const suffix = "\n...(truncated)";
9
+ if (max <= suffix.length) {
10
+ return text.slice(0, max);
11
+ }
12
+ return `${text.slice(0, max - suffix.length)}${suffix}`;
13
+ }
14
+ function splitForMaxLength(text, max, options = {}) {
15
+ if (max === undefined) {
16
+ return [text];
17
+ }
18
+ if (max <= 0) {
19
+ return [""];
20
+ }
21
+ if (text.length <= max) {
22
+ return [text];
23
+ }
24
+ const preserveWhitespace = options.preserveWhitespace ?? false;
25
+ const chunks = [];
26
+ let offset = 0;
27
+ while (offset < text.length) {
28
+ const remainingLength = text.length - offset;
29
+ if (remainingLength <= max) {
30
+ chunks.push(text.slice(offset));
31
+ break;
32
+ }
33
+ if (preserveWhitespace) {
34
+ chunks.push(text.slice(offset, offset + max));
35
+ offset += max;
36
+ continue;
37
+ }
38
+ const hardLimit = offset + max;
39
+ let splitAt = text.lastIndexOf("\n", hardLimit);
40
+ if (splitAt < offset + Math.floor(max * 0.6)) {
41
+ splitAt = hardLimit;
42
+ }
43
+ else {
44
+ splitAt += 1;
45
+ }
46
+ chunks.push(text.slice(offset, splitAt));
47
+ offset = splitAt;
48
+ }
49
+ return chunks;
50
+ }
51
+ export class EventForwarder {
52
+ connector;
53
+ inbound;
54
+ logger;
55
+ rootMessageId;
56
+ responseText = "";
57
+ appendEmittedText = "";
58
+ pendingFlush = null;
59
+ flushSerial = Promise.resolve();
60
+ pendingOps = [];
61
+ updateIntervalMs;
62
+ toolMessageMode;
63
+ maxTextLength;
64
+ onOutboundActivity;
65
+ updateStrategy;
66
+ lastEditPrimaryText = null;
67
+ constructor(connector, inbound, rootMessageId, logger, options = {}) {
68
+ this.connector = connector;
69
+ this.inbound = inbound;
70
+ this.logger = logger;
71
+ this.rootMessageId = rootMessageId;
72
+ this.updateIntervalMs = options.updateIntervalMs ?? 400;
73
+ this.toolMessageMode = options.toolMessageMode ?? "none";
74
+ this.onOutboundActivity = options.onOutboundActivity;
75
+ this.updateStrategy = this.connector.capabilities.updateStrategy;
76
+ const capabilityMaxTextLength = this.connector.capabilities.maxTextLength;
77
+ this.maxTextLength = typeof capabilityMaxTextLength === "number" && capabilityMaxTextLength > 0
78
+ ? capabilityMaxTextLength
79
+ : undefined;
80
+ }
81
+ primaryMessageId() {
82
+ return this.rootMessageId;
83
+ }
84
+ handleEvent = (event) => {
85
+ if (event.type === "message_delta") {
86
+ this.responseText += event.delta;
87
+ if (this.updateStrategy !== "final_only") {
88
+ this.scheduleFlush();
89
+ }
90
+ return;
91
+ }
92
+ if (event.type === "message_complete") {
93
+ if (event.text.trim().length > 0) {
94
+ this.responseText = event.text;
95
+ if (this.updateStrategy !== "final_only") {
96
+ void this.flushNow();
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ if (event.type === "tool_start") {
102
+ this.logger.info({
103
+ toolName: event.toolName,
104
+ conversation: `${this.inbound.platform}:${this.inbound.accountId}:${this.inbound.chatId}:${this.inbound.threadId ?? "root"}`,
105
+ }, "Tool execution started");
106
+ if (this.updateStrategy !== "final_only" && this.toolMessageMode === "all") {
107
+ this.enqueueSend(`_-> Running tool: ${event.toolName}_`);
108
+ }
109
+ return;
110
+ }
111
+ if (event.type === "tool_end") {
112
+ const summary = event.output;
113
+ this.logger.info({
114
+ toolName: event.toolName,
115
+ isError: event.isError,
116
+ conversation: `${this.inbound.platform}:${this.inbound.accountId}:${this.inbound.chatId}:${this.inbound.threadId ?? "root"}`,
117
+ }, event.isError ? "Tool execution finished with error" : "Tool execution finished");
118
+ if (this.updateStrategy !== "final_only" &&
119
+ (this.toolMessageMode === "all" || (this.toolMessageMode === "errors" && event.isError))) {
120
+ const prefix = event.isError ? "ERR" : "OK";
121
+ const header = `*${prefix} ${event.toolName}*\n\`\`\`\n`;
122
+ const footer = "\n```";
123
+ const availableSummaryLength = this.maxTextLength === undefined
124
+ ? undefined
125
+ : Math.max(0, this.maxTextLength - header.length - footer.length);
126
+ this.enqueueSend(`${header}${truncate(summary, availableSummaryLength)}${footer}`);
127
+ }
128
+ return;
129
+ }
130
+ if (event.type === "status") {
131
+ if (this.updateStrategy === "final_only") {
132
+ return;
133
+ }
134
+ this.enqueueSend(`_${event.message}_`);
135
+ }
136
+ };
137
+ async finalize() {
138
+ if (this.updateStrategy === "final_only") {
139
+ await this.finalizeFinalOnly();
140
+ await Promise.allSettled(this.pendingOps);
141
+ return;
142
+ }
143
+ if (this.updateStrategy === "append") {
144
+ await this.finalizeAppend();
145
+ await Promise.allSettled(this.pendingOps);
146
+ return;
147
+ }
148
+ await this.finalizeEdit();
149
+ await Promise.allSettled(this.pendingOps);
150
+ }
151
+ baseEnvelope() {
152
+ return {
153
+ platform: this.inbound.platform,
154
+ accountId: this.inbound.accountId,
155
+ chatId: this.inbound.chatId,
156
+ ...(this.inbound.threadId ? { threadId: this.inbound.threadId } : {}),
157
+ };
158
+ }
159
+ scheduleFlush() {
160
+ if (this.pendingFlush)
161
+ return;
162
+ this.pendingFlush = setTimeout(() => {
163
+ void this.flushNow();
164
+ }, this.updateIntervalMs);
165
+ }
166
+ async flushNow() {
167
+ const run = this.flushSerial.then(async () => {
168
+ if (this.pendingFlush) {
169
+ clearTimeout(this.pendingFlush);
170
+ this.pendingFlush = null;
171
+ }
172
+ if (this.responseText.trim().length === 0) {
173
+ return;
174
+ }
175
+ try {
176
+ if (this.updateStrategy === "append") {
177
+ await this.flushAppendProgress();
178
+ return;
179
+ }
180
+ if (this.updateStrategy === "edit") {
181
+ const content = truncate(this.responseText, this.maxTextLength);
182
+ await this.sendEditPrimary(content);
183
+ }
184
+ }
185
+ catch (error) {
186
+ this.logger.warn({
187
+ err: error,
188
+ connectorId: this.inbound.connectorId,
189
+ chatId: this.inbound.chatId,
190
+ targetMessageId: this.rootMessageId,
191
+ contentLength: this.responseText.length,
192
+ updateStrategy: this.updateStrategy,
193
+ }, "Failed to flush streaming update");
194
+ }
195
+ });
196
+ this.flushSerial = run.catch(() => {
197
+ // keep chain alive for future flush calls
198
+ });
199
+ await run;
200
+ }
201
+ enqueueSend(text) {
202
+ const promise = this.connector
203
+ .send({
204
+ ...this.baseEnvelope(),
205
+ mode: "create",
206
+ ...(this.rootMessageId ? { replyToMessageId: this.rootMessageId } : {}),
207
+ text,
208
+ })
209
+ .then(() => {
210
+ this.noteOutboundActivity();
211
+ })
212
+ .catch((error) => {
213
+ this.logger.warn({
214
+ err: error,
215
+ connectorId: this.inbound.connectorId,
216
+ chatId: this.inbound.chatId,
217
+ replyToMessageId: this.rootMessageId,
218
+ }, "Failed to send connector update message");
219
+ });
220
+ this.pendingOps.push(promise);
221
+ }
222
+ async sendEditPrimary(text) {
223
+ if (this.rootMessageId && this.lastEditPrimaryText === text) {
224
+ return;
225
+ }
226
+ if (this.rootMessageId) {
227
+ await this.connector.send({
228
+ ...this.baseEnvelope(),
229
+ mode: "update",
230
+ targetMessageId: this.rootMessageId,
231
+ text,
232
+ });
233
+ this.lastEditPrimaryText = text;
234
+ this.noteOutboundActivity();
235
+ return;
236
+ }
237
+ const created = await this.connector.send({
238
+ ...this.baseEnvelope(),
239
+ mode: "create",
240
+ text,
241
+ });
242
+ this.rootMessageId = created.messageId ?? this.rootMessageId;
243
+ this.lastEditPrimaryText = text;
244
+ this.noteOutboundActivity();
245
+ }
246
+ async sendCreate(text) {
247
+ const created = await this.connector.send({
248
+ ...this.baseEnvelope(),
249
+ mode: "create",
250
+ ...(this.rootMessageId ? { replyToMessageId: this.rootMessageId } : {}),
251
+ text,
252
+ });
253
+ this.rootMessageId = this.rootMessageId ?? created.messageId ?? null;
254
+ this.noteOutboundActivity();
255
+ }
256
+ async flushAppendProgress() {
257
+ if (this.responseText.startsWith(this.appendEmittedText)) {
258
+ const unsent = this.responseText.slice(this.appendEmittedText.length);
259
+ if (unsent.length === 0) {
260
+ return;
261
+ }
262
+ const chunks = splitForMaxLength(unsent, this.maxTextLength, { preserveWhitespace: true });
263
+ for (const chunk of chunks) {
264
+ await this.sendCreate(chunk);
265
+ }
266
+ this.appendEmittedText = this.responseText;
267
+ return;
268
+ }
269
+ const snapshotChunks = splitForMaxLength(this.responseText, this.maxTextLength);
270
+ for (const chunk of snapshotChunks) {
271
+ await this.sendCreate(chunk);
272
+ }
273
+ this.appendEmittedText = this.responseText;
274
+ }
275
+ async finalizeEdit() {
276
+ await this.flushNow();
277
+ if (this.responseText.trim().length === 0) {
278
+ await this.sendEditPrimary("(completed with no text response)");
279
+ return;
280
+ }
281
+ const chunks = splitForMaxLength(this.responseText, this.maxTextLength);
282
+ try {
283
+ await this.sendEditPrimary(chunks[0] ?? "");
284
+ for (const chunk of chunks.slice(1)) {
285
+ await this.sendCreate(chunk);
286
+ }
287
+ }
288
+ catch (error) {
289
+ this.logger.warn({
290
+ err: error,
291
+ connectorId: this.inbound.connectorId,
292
+ chatId: this.inbound.chatId,
293
+ targetMessageId: this.rootMessageId,
294
+ }, "Failed to send split final response");
295
+ }
296
+ }
297
+ async finalizeFinalOnly() {
298
+ if (this.pendingFlush) {
299
+ clearTimeout(this.pendingFlush);
300
+ this.pendingFlush = null;
301
+ }
302
+ try {
303
+ if (this.responseText.trim().length === 0) {
304
+ await this.sendCreate("(completed with no text response)");
305
+ return;
306
+ }
307
+ const chunks = splitForMaxLength(this.responseText, this.maxTextLength);
308
+ for (const chunk of chunks) {
309
+ await this.sendCreate(chunk);
310
+ }
311
+ }
312
+ catch (error) {
313
+ this.logger.warn({
314
+ err: error,
315
+ connectorId: this.inbound.connectorId,
316
+ chatId: this.inbound.chatId,
317
+ updateStrategy: this.updateStrategy,
318
+ }, "Failed to send final-only response");
319
+ }
320
+ }
321
+ async finalizeAppend() {
322
+ await this.flushNow();
323
+ if (this.responseText.trim().length > 0 || this.appendEmittedText.trim().length > 0) {
324
+ return;
325
+ }
326
+ try {
327
+ await this.sendCreate("(completed with no text response)");
328
+ }
329
+ catch (error) {
330
+ this.logger.warn({
331
+ err: error,
332
+ connectorId: this.inbound.connectorId,
333
+ chatId: this.inbound.chatId,
334
+ updateStrategy: this.updateStrategy,
335
+ }, "Failed to send append fallback response");
336
+ }
337
+ }
338
+ noteOutboundActivity() {
339
+ this.onOutboundActivity?.();
340
+ }
341
+ }
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { EventForwarder } from "../event-forwarder.js";
4
+ class FakeConnector {
5
+ id = "connector.test";
6
+ platform = "test";
7
+ name = "test";
8
+ capabilities;
9
+ sent = [];
10
+ sentCount = 0;
11
+ constructor(updateStrategy, maxTextLength) {
12
+ this.capabilities = {
13
+ updateStrategy,
14
+ supportsThread: false,
15
+ supportsTyping: false,
16
+ supportsFileUpload: false,
17
+ ...(maxTextLength !== undefined ? { maxTextLength } : {}),
18
+ };
19
+ }
20
+ async start(_ctx) { }
21
+ async send(message) {
22
+ this.sent.push(message);
23
+ this.sentCount += 1;
24
+ return { messageId: `msg-${this.sentCount}` };
25
+ }
26
+ async stop() { }
27
+ }
28
+ const noopLogger = {
29
+ info: () => { },
30
+ warn: () => { },
31
+ error: () => { },
32
+ debug: () => { },
33
+ };
34
+ function createInbound() {
35
+ return {
36
+ connectorId: "connector.test",
37
+ platform: "test",
38
+ accountId: "bot",
39
+ routeId: "route.main",
40
+ routeChannelId: "route.main",
41
+ chatId: "chat.main",
42
+ messageId: "inbound-1",
43
+ userId: "user-1",
44
+ text: "hello",
45
+ attachments: [],
46
+ timestampMs: Date.now(),
47
+ raw: {},
48
+ isDirectMessage: false,
49
+ mentionedBot: true,
50
+ };
51
+ }
52
+ function sleep(ms) {
53
+ return new Promise((resolve) => {
54
+ setTimeout(resolve, ms);
55
+ });
56
+ }
57
+ test("edit strategy keeps create+update flow", async () => {
58
+ const connector = new FakeConnector("edit");
59
+ const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
60
+ updateIntervalMs: 1,
61
+ });
62
+ forwarder.handleEvent({ type: "message_complete", text: "hello" });
63
+ await sleep(5);
64
+ forwarder.handleEvent({ type: "message_complete", text: "hello world" });
65
+ await sleep(5);
66
+ await forwarder.finalize();
67
+ assert.equal(connector.sent.length, 2);
68
+ assert.equal(connector.sent[0]?.mode, "create");
69
+ assert.equal(connector.sent[0]?.text, "hello");
70
+ assert.equal(connector.sent[1]?.mode, "update");
71
+ assert.equal(connector.sent[1]?.text, "hello world");
72
+ });
73
+ test("final_only suppresses tool/status outbound messages", async () => {
74
+ const connector = new FakeConnector("final_only");
75
+ const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
76
+ toolMessageMode: "all",
77
+ updateIntervalMs: 1,
78
+ });
79
+ forwarder.handleEvent({ type: "status", message: "starting" });
80
+ forwarder.handleEvent({ type: "tool_start", toolName: "bash" });
81
+ forwarder.handleEvent({ type: "message_delta", delta: "hello " });
82
+ forwarder.handleEvent({ type: "tool_end", toolName: "bash", isError: false, output: "ok" });
83
+ forwarder.handleEvent({ type: "message_delta", delta: "world" });
84
+ await forwarder.finalize();
85
+ assert.equal(connector.sent.length, 1);
86
+ assert.equal(connector.sent[0]?.mode, "create");
87
+ assert.equal(connector.sent[0]?.text, "hello world");
88
+ });
89
+ test("final_only splits long final text into multiple create messages", async () => {
90
+ const connector = new FakeConnector("final_only", 5);
91
+ const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger);
92
+ forwarder.handleEvent({ type: "message_complete", text: "12345678901" });
93
+ await forwarder.finalize();
94
+ assert.equal(connector.sent.length, 3);
95
+ assert.deepEqual(connector.sent.map((message) => ({ mode: message.mode, text: message.text })), [
96
+ { mode: "create", text: "12345" },
97
+ { mode: "create", text: "67890" },
98
+ { mode: "create", text: "1" },
99
+ ]);
100
+ });
101
+ test("append sends streaming increments as create messages only", async () => {
102
+ const connector = new FakeConnector("append");
103
+ const forwarder = new EventForwarder(connector, createInbound(), null, noopLogger, {
104
+ updateIntervalMs: 5,
105
+ });
106
+ forwarder.handleEvent({ type: "message_delta", delta: "hello" });
107
+ await sleep(20);
108
+ forwarder.handleEvent({ type: "message_delta", delta: " world" });
109
+ await sleep(20);
110
+ await forwarder.finalize();
111
+ assert.deepEqual(connector.sent.map((message) => message.mode), ["create", "create"]);
112
+ assert.deepEqual(connector.sent.map((message) => message.text), ["hello", " world"]);
113
+ });