@lobu/worker 6.1.1 → 7.0.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 (82) hide show
  1. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  2. package/dist/embedded/just-bash-bootstrap.js +26 -2
  3. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  4. package/dist/gateway/gateway-integration.js +4 -4
  5. package/dist/gateway/gateway-integration.js.map +1 -1
  6. package/dist/gateway/message-batcher.d.ts.map +1 -1
  7. package/dist/gateway/message-batcher.js +3 -5
  8. package/dist/gateway/message-batcher.js.map +1 -1
  9. package/dist/gateway/sse-client.d.ts +1 -0
  10. package/dist/gateway/sse-client.d.ts.map +1 -1
  11. package/dist/gateway/sse-client.js +8 -0
  12. package/dist/gateway/sse-client.js.map +1 -1
  13. package/dist/openclaw/worker.d.ts +0 -1
  14. package/dist/openclaw/worker.d.ts.map +1 -1
  15. package/dist/openclaw/worker.js +18 -75
  16. package/dist/openclaw/worker.js.map +1 -1
  17. package/dist/shared/tool-implementations.d.ts.map +1 -1
  18. package/dist/shared/tool-implementations.js +37 -13
  19. package/dist/shared/tool-implementations.js.map +1 -1
  20. package/package.json +14 -4
  21. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  22. package/src/__tests__/custom-tools.test.ts +92 -0
  23. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  24. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  25. package/src/__tests__/embedded-tools.test.ts +744 -0
  26. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  27. package/src/__tests__/exec-sandbox.test.ts +550 -0
  28. package/src/__tests__/generated-media.test.ts +142 -0
  29. package/src/__tests__/instructions.test.ts +60 -0
  30. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  31. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  32. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  33. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  34. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  35. package/src/__tests__/memory-flush.test.ts +64 -0
  36. package/src/__tests__/message-batcher.test.ts +247 -0
  37. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  38. package/src/__tests__/model-resolver.test.ts +156 -0
  39. package/src/__tests__/processor-harden.test.ts +269 -0
  40. package/src/__tests__/processor.test.ts +225 -0
  41. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  42. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  43. package/src/__tests__/sandbox-leak.test.ts +167 -0
  44. package/src/__tests__/setup.ts +102 -0
  45. package/src/__tests__/sse-client-harden.test.ts +588 -0
  46. package/src/__tests__/sse-client.test.ts +90 -0
  47. package/src/__tests__/tool-implementations.test.ts +196 -0
  48. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  49. package/src/__tests__/tool-policy.test.ts +269 -0
  50. package/src/__tests__/worker.test.ts +89 -0
  51. package/src/core/error-handler.ts +62 -0
  52. package/src/core/project-scanner.ts +65 -0
  53. package/src/core/types.ts +128 -0
  54. package/src/core/workspace.ts +89 -0
  55. package/src/embedded/exec-sandbox.ts +372 -0
  56. package/src/embedded/just-bash-bootstrap.ts +543 -0
  57. package/src/embedded/mcp-cli-commands.ts +402 -0
  58. package/src/gateway/gateway-integration.ts +298 -0
  59. package/src/gateway/message-batcher.ts +123 -0
  60. package/src/gateway/sse-client.ts +951 -0
  61. package/src/gateway/types.ts +68 -0
  62. package/src/index.ts +141 -0
  63. package/src/instructions/builder.ts +45 -0
  64. package/src/instructions/providers.ts +27 -0
  65. package/src/modules/lifecycle.ts +92 -0
  66. package/src/openclaw/custom-tools.ts +315 -0
  67. package/src/openclaw/instructions.ts +36 -0
  68. package/src/openclaw/model-resolver.ts +150 -0
  69. package/src/openclaw/plugin-loader.ts +427 -0
  70. package/src/openclaw/processor.ts +198 -0
  71. package/src/openclaw/sandbox-leak.ts +105 -0
  72. package/src/openclaw/session-context.ts +320 -0
  73. package/src/openclaw/tool-policy.ts +248 -0
  74. package/src/openclaw/tools.ts +277 -0
  75. package/src/openclaw/worker.ts +1847 -0
  76. package/src/server.ts +334 -0
  77. package/src/shared/audio-provider-suggestions.ts +132 -0
  78. package/src/shared/processor-utils.ts +33 -0
  79. package/src/shared/provider-auth-hints.ts +68 -0
  80. package/src/shared/tool-display-config.ts +75 -0
  81. package/src/shared/tool-implementations.ts +940 -0
  82. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Worker-side MCP-as-CLI bootstrap for embedded deployment mode.
3
+ *
4
+ * Registers one `just-bash` custom command per MCP server (e.g. `lobu`,
5
+ * `gmail`). The agent invokes MCP tools via the sandboxed bash:
6
+ *
7
+ * lobu search_memory <<<'{"query":"foo"}'
8
+ * lobu --help
9
+ * lobu save_memory --schema
10
+ * lobu auth login
11
+ *
12
+ * Payload is read from `ctx.stdin` as JSON. If stdin is empty, falls back to
13
+ * `args[1]` as a JSON string (defense-in-depth for models that write the JSON
14
+ * inline).
15
+ */
16
+ import type { McpStatus, McpToolDef } from "@lobu/core";
17
+ import { createLogger } from "@lobu/core";
18
+ import type { GatewayParams } from "../shared/tool-implementations";
19
+ import { callMcpTool } from "../shared/tool-implementations";
20
+ import { isDirectPackageInstallCommand } from "../openclaw/tool-policy";
21
+
22
+ const logger = createLogger("mcp-cli");
23
+
24
+ /** Names reserved by just-bash / POSIX shells that we must not shadow. */
25
+ const RESERVED_COMMAND_NAMES = new Set([
26
+ "cd",
27
+ "echo",
28
+ "export",
29
+ "test",
30
+ "true",
31
+ "false",
32
+ "pwd",
33
+ "set",
34
+ "unset",
35
+ "exit",
36
+ "source",
37
+ ".",
38
+ ":",
39
+ "[",
40
+ ]);
41
+
42
+ /**
43
+ * Mutable snapshot of MCP session state. CLI handlers read through `current`
44
+ * so that `auth login|check|logout` can refresh tools/state via `refresh()`
45
+ * without rebuilding the Bash instance. New servers discovered after startup
46
+ * are not retro-registered — they require a worker restart.
47
+ */
48
+ export interface McpRuntimeState {
49
+ mcpTools: Record<string, McpToolDef[]>;
50
+ mcpStatus: McpStatus[];
51
+ mcpContext: Record<string, string>;
52
+ }
53
+
54
+ export interface McpRuntimeRef {
55
+ current: McpRuntimeState;
56
+ /** Re-fetch session context and return a fresh snapshot, or `null` on failure. */
57
+ refresh?: () => Promise<McpRuntimeState | null>;
58
+ }
59
+
60
+ export interface McpCliCommand {
61
+ name: string;
62
+ execute: (
63
+ args: string[],
64
+ ctx: { stdin?: string; signal?: AbortSignal }
65
+ ) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
66
+ }
67
+
68
+ export interface McpCliDeps {
69
+ callTool: typeof callMcpTool;
70
+ }
71
+
72
+ const DEFAULT_DEPS: McpCliDeps = {
73
+ callTool: callMcpTool,
74
+ };
75
+
76
+ /** Check whether an MCP id would collide with a bash builtin or deny-prefix. */
77
+ export function isMcpIdReserved(mcpId: string): string | null {
78
+ if (RESERVED_COMMAND_NAMES.has(mcpId)) {
79
+ return `reserved bash builtin`;
80
+ }
81
+ // Probe against the package-install denylist using invocations that match
82
+ // its actual patterns (install/add/require/upgrade/etc.).
83
+ const probes = [
84
+ `${mcpId} install`,
85
+ `${mcpId} i`,
86
+ `${mcpId} add`,
87
+ `${mcpId} upgrade`,
88
+ `${mcpId} require`,
89
+ mcpId,
90
+ ];
91
+ if (probes.some((p) => isDirectPackageInstallCommand(p))) {
92
+ return `matches package-install denylist`;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function truncate(text: string, max: number): string {
98
+ if (!text) return "";
99
+ const clean = text.replace(/\s+/g, " ").trim();
100
+ return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
101
+ }
102
+
103
+ function renderHelp(
104
+ mcpId: string,
105
+ state: McpRuntimeState
106
+ ): { stdout: string; exitCode: number } {
107
+ const tools = state.mcpTools[mcpId] ?? [];
108
+ const status = state.mcpStatus.find((s) => s.id === mcpId);
109
+ const contextPrefix = state.mcpContext[mcpId];
110
+ const lines: string[] = [];
111
+
112
+ lines.push(`${mcpId} — MCP server CLI`);
113
+ if (contextPrefix) {
114
+ lines.push(contextPrefix);
115
+ }
116
+ lines.push("");
117
+ lines.push("Usage:");
118
+ lines.push(` ${mcpId} <tool> <<'EOF'`);
119
+ lines.push(` { ...json args... }`);
120
+ lines.push(` EOF`);
121
+ lines.push("");
122
+ lines.push(` ${mcpId} <tool> --schema # print the JSON schema`);
123
+ lines.push(` ${mcpId} --help # this message`);
124
+ if (status?.requiresAuth) {
125
+ lines.push(` ${mcpId} auth login|check|logout`);
126
+ }
127
+ lines.push("");
128
+
129
+ if (tools.length === 0) {
130
+ lines.push(
131
+ "(no tools discovered — the server may need authentication or configuration)"
132
+ );
133
+ } else {
134
+ lines.push("Tools:");
135
+ for (const tool of tools) {
136
+ const desc = truncate(tool.description ?? "", 80);
137
+ lines.push(` ${tool.name}${desc ? ` ${desc}` : ""}`);
138
+ }
139
+ }
140
+
141
+ return { stdout: `${lines.join("\n")}\n`, exitCode: 0 };
142
+ }
143
+
144
+ function findTool(
145
+ mcpId: string,
146
+ toolName: string,
147
+ state: McpRuntimeState
148
+ ): McpToolDef | undefined {
149
+ return state.mcpTools[mcpId]?.find((t) => t.name === toolName);
150
+ }
151
+
152
+ export function parsePayload(
153
+ stdin: string | undefined,
154
+ inlineArg: string | undefined
155
+ ):
156
+ | { ok: true; payload: Record<string, unknown> }
157
+ | { ok: false; error: string } {
158
+ const raw = stdin?.trim() || inlineArg?.trim() || "";
159
+ if (!raw) {
160
+ return { ok: true, payload: {} };
161
+ }
162
+ try {
163
+ const parsed = JSON.parse(raw);
164
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
165
+ return { ok: false, error: "expected a JSON object payload" };
166
+ }
167
+ return { ok: true, payload: parsed as Record<string, unknown> };
168
+ } catch (err) {
169
+ return {
170
+ ok: false,
171
+ error: `invalid JSON payload: ${err instanceof Error ? err.message : String(err)}`,
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Build the execute handler for a single MCP server CLI.
178
+ * Exposed for unit testing.
179
+ */
180
+ export function buildMcpServerHandler(
181
+ mcpId: string,
182
+ ref: McpRuntimeRef,
183
+ gw: GatewayParams,
184
+ deps: McpCliDeps = DEFAULT_DEPS
185
+ ): McpCliCommand["execute"] {
186
+ return async (args, ctx) => {
187
+ const subcommand = args[0];
188
+ const state = ref.current;
189
+
190
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
191
+ const { stdout, exitCode } = renderHelp(mcpId, state);
192
+ return { stdout, stderr: "", exitCode };
193
+ }
194
+
195
+ if (subcommand === "auth") {
196
+ return runAuthSubcommand(mcpId, args.slice(1), gw, ref);
197
+ }
198
+
199
+ // <tool> --schema
200
+ if (args[1] === "--schema") {
201
+ const tool = findTool(mcpId, subcommand, state);
202
+ if (!tool) {
203
+ return {
204
+ stdout: "",
205
+ stderr: `unknown tool: ${subcommand}. Run \`${mcpId} --help\`.\n`,
206
+ exitCode: 2,
207
+ };
208
+ }
209
+ const schema = tool.inputSchema ?? {};
210
+ return {
211
+ stdout: `${JSON.stringify(schema, null, 2)}\n`,
212
+ stderr: "",
213
+ exitCode: 0,
214
+ };
215
+ }
216
+
217
+ // <tool> [json]
218
+ const tool = findTool(mcpId, subcommand, state);
219
+ if (!tool) {
220
+ return {
221
+ stdout: "",
222
+ stderr: `unknown tool: ${subcommand}. Run \`${mcpId} --help\`.\n`,
223
+ exitCode: 2,
224
+ };
225
+ }
226
+
227
+ const parsed = parsePayload(ctx.stdin, args[1]);
228
+ if (!parsed.ok) {
229
+ return { stdout: "", stderr: `${parsed.error}\n`, exitCode: 2 };
230
+ }
231
+
232
+ try {
233
+ const result = await deps.callTool(gw, mcpId, subcommand, parsed.payload);
234
+ const text = result.content
235
+ .filter((c) => c.type === "text")
236
+ .map((c) => c.text)
237
+ .join("\n");
238
+ return { stdout: text ? `${text}\n` : "", stderr: "", exitCode: 0 };
239
+ } catch (err) {
240
+ return {
241
+ stdout: "",
242
+ stderr: `${err instanceof Error ? err.message : String(err)}\n`,
243
+ exitCode: 1,
244
+ };
245
+ }
246
+ };
247
+ }
248
+
249
+ async function refreshRef(
250
+ ref: McpRuntimeRef,
251
+ mcpId: string,
252
+ verb: string
253
+ ): Promise<void> {
254
+ if (!ref.refresh) return;
255
+ try {
256
+ const fresh = await ref.refresh();
257
+ if (fresh) ref.current = fresh;
258
+ } catch (err) {
259
+ logger.warn(
260
+ `Failed to refresh MCP state after ${mcpId} auth ${verb}: ${err instanceof Error ? err.message : String(err)}`
261
+ );
262
+ }
263
+ }
264
+
265
+ async function runAuthSubcommand(
266
+ mcpId: string,
267
+ args: string[],
268
+ gw: GatewayParams,
269
+ ref: McpRuntimeRef
270
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
271
+ const verb = args[0];
272
+ // Lazy import to avoid a heavy dependency cycle in tests.
273
+ const impl = await import("../shared/tool-implementations");
274
+
275
+ if (verb === "login") {
276
+ const res = await impl.startMcpLogin(gw, { mcpId });
277
+ const text = extractText(res.content);
278
+ return {
279
+ stdout: `${summariseAuthStart(text, mcpId)}\n`,
280
+ stderr: "",
281
+ exitCode: 0,
282
+ };
283
+ }
284
+
285
+ if (verb === "check") {
286
+ const res = await impl.checkMcpLogin(gw, { mcpId });
287
+ const text = extractText(res.content);
288
+ const parsed = tryJson(text);
289
+ if (parsed?.authenticated === true) {
290
+ await refreshRef(ref, mcpId, "check");
291
+ }
292
+ return {
293
+ stdout: `${summariseAuthCheck(parsed, mcpId, text)}\n`,
294
+ stderr: "",
295
+ exitCode: 0,
296
+ };
297
+ }
298
+
299
+ if (verb === "logout") {
300
+ const res = await impl.logoutMcp(gw, { mcpId });
301
+ const text = extractText(res.content);
302
+ // Tools that required auth are now unreachable — refresh so the next
303
+ // invocation sees the empty state.
304
+ await refreshRef(ref, mcpId, "logout");
305
+ return { stdout: `${text}\n`, stderr: "", exitCode: 0 };
306
+ }
307
+
308
+ return {
309
+ stdout: "",
310
+ stderr: `unknown auth subcommand: ${verb ?? "(none)"}. Use login|check|logout.\n`,
311
+ exitCode: 2,
312
+ };
313
+ }
314
+
315
+ function extractText(content: Array<{ type: string; text?: string }>): string {
316
+ return content
317
+ .filter(
318
+ (c): c is { type: "text"; text: string } =>
319
+ c.type === "text" && typeof c.text === "string"
320
+ )
321
+ .map((c) => c.text)
322
+ .join("\n");
323
+ }
324
+
325
+ export function summariseAuthStart(rawText: string, mcpId: string): string {
326
+ const parsed = tryJson(rawText);
327
+ if (!parsed) return rawText;
328
+ if (parsed.status === "already_authenticated") {
329
+ return JSON.stringify({ status: "already_authenticated", mcp_id: mcpId });
330
+ }
331
+ if (parsed.status === "login_started") {
332
+ const interactionPosted = Boolean(parsed.interaction_posted);
333
+ // If the link-button side-channel didn't fire, fall back to the raw payload
334
+ // so the verification URL + user_code remain reachable by the model.
335
+ if (!interactionPosted) return rawText;
336
+ return JSON.stringify({
337
+ status: "login_started",
338
+ mcp_id: mcpId,
339
+ interaction_posted: true,
340
+ message: `Login link sent directly to the user. Run \`${mcpId} auth check\` after they confirm.`,
341
+ });
342
+ }
343
+ return rawText;
344
+ }
345
+
346
+ export function summariseAuthCheck(
347
+ parsed: Record<string, unknown> | null,
348
+ mcpId: string,
349
+ fallback: string
350
+ ): string {
351
+ if (!parsed) return fallback;
352
+ return JSON.stringify({
353
+ status: parsed.status ?? "unknown",
354
+ mcp_id: mcpId,
355
+ authenticated: parsed.authenticated ?? false,
356
+ });
357
+ }
358
+
359
+ function tryJson(text: string): Record<string, unknown> | null {
360
+ try {
361
+ const v = JSON.parse(text);
362
+ return v && typeof v === "object" && !Array.isArray(v)
363
+ ? (v as Record<string, unknown>)
364
+ : null;
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Build one command per MCP server in `ref.current.mcpStatus`, including
372
+ * servers that currently have no discovered tools (so `<server> auth login`
373
+ * still works for unauthenticated servers).
374
+ */
375
+ export function buildMcpCliCommands(
376
+ ref: McpRuntimeRef,
377
+ gw: GatewayParams,
378
+ deps: Partial<McpCliDeps> = {}
379
+ ): McpCliCommand[] {
380
+ const resolvedDeps: McpCliDeps = { ...DEFAULT_DEPS, ...deps };
381
+ const state = ref.current;
382
+ const serverIds = new Set<string>([
383
+ ...Object.keys(state.mcpTools ?? {}),
384
+ ...(state.mcpStatus ?? []).map((s) => s.id),
385
+ ]);
386
+
387
+ const commands: McpCliCommand[] = [];
388
+ for (const mcpId of serverIds) {
389
+ const reserved = isMcpIdReserved(mcpId);
390
+ if (reserved) {
391
+ logger.warn(
392
+ `Skipping MCP CLI registration for "${mcpId}" — ${reserved}. Rename the MCP server to enable CLI mode.`
393
+ );
394
+ continue;
395
+ }
396
+ commands.push({
397
+ name: mcpId,
398
+ execute: buildMcpServerHandler(mcpId, ref, gw, resolvedDeps),
399
+ });
400
+ }
401
+ return commands;
402
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * HTTP implementation of WorkerTransport
3
+ * Sends worker responses to gateway via HTTP POST requests
4
+ */
5
+
6
+ import {
7
+ createLogger,
8
+ retryWithBackoff,
9
+ type WorkerTransport,
10
+ type WorkerTransportConfig,
11
+ } from "@lobu/core";
12
+ import type { ResponseData } from "./types";
13
+
14
+ const logger = createLogger("http-worker-transport");
15
+
16
+ /**
17
+ * HTTP transport for worker-to-gateway communication
18
+ * Implements retry logic and deduplication for streaming responses
19
+ */
20
+ export class HttpWorkerTransport implements WorkerTransport {
21
+ private gatewayUrl: string;
22
+ private workerToken: string;
23
+ private userId: string;
24
+ private channelId: string;
25
+ private conversationId: string;
26
+ private originalMessageTs: string;
27
+ private botResponseTs?: string;
28
+ public processedMessageIds: string[] = [];
29
+ private jobId?: string;
30
+ private moduleData?: Record<string, unknown>;
31
+ private teamId: string;
32
+ private platform?: string;
33
+ private platformMetadata?: Record<string, unknown>;
34
+ private accumulatedStreamContent: string[] = [];
35
+ private lastStreamDelta: string = "";
36
+
37
+ constructor(config: WorkerTransportConfig) {
38
+ this.gatewayUrl = config.gatewayUrl;
39
+ this.workerToken = config.workerToken;
40
+ this.userId = config.userId;
41
+ this.channelId = config.channelId;
42
+ this.conversationId = config.conversationId;
43
+ this.originalMessageTs = config.originalMessageTs;
44
+ this.botResponseTs = config.botResponseTs;
45
+ this.teamId = config.teamId;
46
+ this.platform = config.platform;
47
+ this.platformMetadata = config.platformMetadata;
48
+ this.processedMessageIds = config.processedMessageIds || [];
49
+ }
50
+
51
+ setJobId(jobId: string): void {
52
+ this.jobId = jobId;
53
+ }
54
+
55
+ setModuleData(moduleData: Record<string, unknown>): void {
56
+ this.moduleData = moduleData;
57
+ }
58
+
59
+ async signalDone(finalDelta?: string): Promise<void> {
60
+ // Send final delta if there is one
61
+ if (finalDelta) {
62
+ await this.sendStreamDelta(finalDelta, false, true);
63
+ }
64
+ await this.signalCompletion();
65
+ }
66
+
67
+ async sendStreamDelta(
68
+ delta: string,
69
+ isFullReplacement: boolean = false,
70
+ isFinal: boolean = false
71
+ ): Promise<void> {
72
+ let actualDelta = delta;
73
+
74
+ // Handle final result with deduplication
75
+ if (isFinal) {
76
+ logger.info(`🔍 Processing final result with deduplication`);
77
+ logger.info(`Final text length: ${delta.length} chars`);
78
+ const accumulatedStr = this.accumulatedStreamContent.join("");
79
+ const accumulatedLength = accumulatedStr.length;
80
+ logger.info(`Accumulated length: ${accumulatedLength} chars`);
81
+
82
+ // Check if final result is identical to what we've already sent
83
+ if (delta === accumulatedStr) {
84
+ logger.info(
85
+ `✅ Final result is identical to accumulated content - skipping duplicate`
86
+ );
87
+ return;
88
+ }
89
+
90
+ // Check if accumulated content is a prefix of final result
91
+ if (delta.startsWith(accumulatedStr)) {
92
+ // Only send the missing part
93
+ actualDelta = delta.slice(accumulatedLength);
94
+ if (actualDelta.length === 0) {
95
+ logger.info(
96
+ `✅ Final result fully contained in accumulated content - skipping`
97
+ );
98
+ return;
99
+ }
100
+ logger.info(
101
+ `📝 Final result has ${actualDelta.length} new chars - sending delta only`
102
+ );
103
+ } else if (accumulatedLength > 0) {
104
+ const normalizedFinal = this.normalizeForComparison(delta);
105
+ const normalizedLastDelta = this.normalizeForComparison(
106
+ this.lastStreamDelta
107
+ );
108
+
109
+ if (
110
+ normalizedFinal.length > 0 &&
111
+ normalizedFinal === normalizedLastDelta
112
+ ) {
113
+ logger.info(
114
+ `✅ Final result matches last streamed delta (normalized) - skipping duplicate`
115
+ );
116
+ return;
117
+ }
118
+
119
+ // Content differs - log warning and send full final result
120
+ logger.warn(`⚠️ Final result differs from accumulated content!`);
121
+ logger.warn(
122
+ `First 100 chars of accumulated: ${accumulatedStr.substring(0, 100)}`
123
+ );
124
+ logger.warn(`First 100 chars of final: ${delta.substring(0, 100)}`);
125
+ logger.info(`📤 Sending full final result (${delta.length} chars)`);
126
+ }
127
+ }
128
+
129
+ // Track accumulated content for deduplication using array buffer (O(1) append)
130
+ if (!isFullReplacement) {
131
+ this.accumulatedStreamContent.push(actualDelta);
132
+ } else {
133
+ this.accumulatedStreamContent = [actualDelta];
134
+ }
135
+ this.lastStreamDelta = actualDelta;
136
+
137
+ await this.sendResponse(
138
+ this.buildBaseResponse({
139
+ delta: actualDelta,
140
+ moduleData: this.moduleData,
141
+ isFullReplacement,
142
+ })
143
+ );
144
+ }
145
+
146
+ async signalCompletion(): Promise<void> {
147
+ await this.sendResponse(
148
+ this.buildBaseResponse({
149
+ processedMessageIds: this.processedMessageIds,
150
+ moduleData: this.moduleData,
151
+ })
152
+ );
153
+ }
154
+
155
+ async signalError(error: Error, errorCode?: string): Promise<void> {
156
+ await this.sendResponse(
157
+ this.buildBaseResponse({
158
+ error: error.message,
159
+ ...(errorCode && { errorCode }),
160
+ })
161
+ );
162
+ }
163
+
164
+ async sendStatusUpdate(elapsedSeconds: number, state: string): Promise<void> {
165
+ await this.sendResponse(
166
+ this.buildBaseResponse({
167
+ statusUpdate: { elapsedSeconds, state },
168
+ })
169
+ );
170
+ }
171
+
172
+ async sendCustomEvent(
173
+ name: string,
174
+ data: Record<string, unknown>
175
+ ): Promise<void> {
176
+ await this.sendResponse(
177
+ this.buildBaseResponse({
178
+ customEvent: { name, data },
179
+ })
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Build base response payload with common fields shared across all response types
185
+ */
186
+ private buildBaseResponse(
187
+ additionalFields?: Partial<ResponseData>
188
+ ): ResponseData {
189
+ return {
190
+ messageId: this.originalMessageTs,
191
+ channelId: this.channelId,
192
+ conversationId: this.conversationId,
193
+ userId: this.userId,
194
+ teamId: this.teamId,
195
+ timestamp: Date.now(),
196
+ originalMessageId: this.originalMessageTs,
197
+ botResponseId: this.botResponseTs,
198
+ ...additionalFields,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Build exec response payload with exec-specific fields
204
+ */
205
+ private buildExecResponse(
206
+ execId: string,
207
+ additionalFields: Partial<ResponseData>
208
+ ): ResponseData {
209
+ return this.buildBaseResponse({ execId, ...additionalFields });
210
+ }
211
+
212
+ /**
213
+ * Send exec output (stdout/stderr) to gateway
214
+ */
215
+ async sendExecOutput(
216
+ execId: string,
217
+ stream: "stdout" | "stderr",
218
+ content: string
219
+ ): Promise<void> {
220
+ await this.sendResponse(
221
+ this.buildExecResponse(execId, { delta: content, execStream: stream })
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Send exec completion to gateway
227
+ */
228
+ async sendExecComplete(execId: string, exitCode: number): Promise<void> {
229
+ await this.sendResponse(
230
+ this.buildExecResponse(execId, { execExitCode: exitCode })
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Send exec error to gateway
236
+ */
237
+ async sendExecError(execId: string, errorMessage: string): Promise<void> {
238
+ await this.sendResponse(
239
+ this.buildExecResponse(execId, { error: errorMessage })
240
+ );
241
+ }
242
+
243
+ private async sendResponse(data: ResponseData): Promise<void> {
244
+ const responseUrl = `${this.gatewayUrl}/worker/response`;
245
+ const basePayload = {
246
+ ...data,
247
+ ...(this.platform && !data.platform ? { platform: this.platform } : {}),
248
+ ...(!data.platformMetadata && this.platformMetadata
249
+ ? { platformMetadata: this.platformMetadata }
250
+ : {}),
251
+ };
252
+ const payload = this.jobId
253
+ ? { jobId: this.jobId, ...basePayload }
254
+ : basePayload;
255
+
256
+ await retryWithBackoff(
257
+ async () => {
258
+ // Don't `JSON.stringify(payload)` just to truncate it for a log line —
259
+ // that's a full serialize-then-discard on the per-delta hot path
260
+ // (and platformMetadata can be large). Log the identifying fields only.
261
+ logger.info(
262
+ `[WORKER-HTTP] Sending to ${responseUrl}: messageId=${payload.messageId ?? ""}${
263
+ payload.delta ? ` deltaLength=${payload.delta.length}` : ""
264
+ }${payload.statusUpdate ? " statusUpdate" : ""}${payload.customEvent ? ` customEvent=${payload.customEvent.name}` : ""}`
265
+ );
266
+
267
+ const response = await fetch(responseUrl, {
268
+ method: "POST",
269
+ headers: {
270
+ Authorization: `Bearer ${this.workerToken}`,
271
+ "Content-Type": "application/json",
272
+ },
273
+ body: JSON.stringify(payload),
274
+ signal: AbortSignal.timeout(30_000),
275
+ });
276
+
277
+ if (!response.ok) {
278
+ throw new Error(
279
+ `Failed to send response to dispatcher: ${response.status} ${response.statusText}`
280
+ );
281
+ }
282
+
283
+ logger.debug("Response sent to dispatcher successfully");
284
+ },
285
+ {
286
+ maxRetries: 2,
287
+ baseDelay: 1000,
288
+ onRetry: (attempt, error) => {
289
+ logger.warn(`Failed to send response (attempt ${attempt}/2):`, error);
290
+ },
291
+ }
292
+ );
293
+ }
294
+
295
+ private normalizeForComparison(text: string): string {
296
+ return text.replace(/\r\n/g, "\n").trim();
297
+ }
298
+ }