compass-agent 2.0.4
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/.github/workflows/publish.yml +24 -0
- package/README.md +294 -0
- package/bun.lock +326 -0
- package/manifest.yml +66 -0
- package/package.json +25 -0
- package/src/app.ts +786 -0
- package/src/db.ts +398 -0
- package/src/handlers/assistant.ts +332 -0
- package/src/handlers/cwd-modal.test.ts +188 -0
- package/src/handlers/cwd-modal.ts +63 -0
- package/src/handlers/setStatus.test.ts +118 -0
- package/src/handlers/stream.test.ts +137 -0
- package/src/handlers/stream.ts +908 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/thread-context.ts +99 -0
- package/src/lib/worktree.ts +103 -0
- package/src/mcp/server.ts +286 -0
- package/src/types.ts +118 -0
- package/src/ui/blocks.ts +155 -0
- package/tests/blocks.test.ts +73 -0
- package/tests/db.test.ts +261 -0
- package/tests/thread-context.test.ts +183 -0
- package/tests/utils.test.ts +75 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI streaming handler with agentic task visualization.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the Claude CLI, parses NDJSON events, streams text to Slack via
|
|
5
|
+
* chatStream (with chat.update fallback), emits TaskUpdateChunks for tool
|
|
6
|
+
* calls, and logs usage on completion.
|
|
7
|
+
*
|
|
8
|
+
* Supports two display modes:
|
|
9
|
+
* - "plan" – used when Claude enters plan mode (EnterPlanMode tool).
|
|
10
|
+
* Shows a plan_update title + grouped task_update steps.
|
|
11
|
+
* - "timeline" – default mode for normal tool-use / implementation.
|
|
12
|
+
* Shows individual task cards interleaved with streamed text.
|
|
13
|
+
*
|
|
14
|
+
* The display mode is detected from the first content_block_start event and
|
|
15
|
+
* the streamer is created lazily so we can pick the right mode.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "child_process";
|
|
19
|
+
import { appendFileSync, writeFileSync } from "fs";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { getTeachings, upsertSession, addUsageLog } from "../db.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildBlocks, buildStopOnlyBlocks, buildFeedbackBlock, buildDisclaimerBlock,
|
|
24
|
+
} from "../ui/blocks.ts";
|
|
25
|
+
import { log, logErr } from "../lib/log.ts";
|
|
26
|
+
import type { HandleClaudeStreamOpts } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
// ── Temporary debug: dump raw NDJSON to file ──────────────────
|
|
29
|
+
const STREAM_DEBUG = process.env.STREAM_DEBUG === "1";
|
|
30
|
+
const STREAM_DEBUG_FILE = join(import.meta.dir, "..", "stream-debug.jsonl");
|
|
31
|
+
|
|
32
|
+
const CLAUDE_PATH = process.env.CLAUDE_PATH || "claude";
|
|
33
|
+
const UPDATE_INTERVAL_MS = 750;
|
|
34
|
+
|
|
35
|
+
/** Tools that are internal / meta and should be hidden or shown differently */
|
|
36
|
+
const HIDDEN_TOOLS = new Set(["EnterPlanMode", "ExitPlanMode"]);
|
|
37
|
+
|
|
38
|
+
const TOOL_STATUS_MAP: Record<string, string> = {
|
|
39
|
+
Read: "is reading files...",
|
|
40
|
+
Write: "is writing code...",
|
|
41
|
+
Edit: "is editing code...",
|
|
42
|
+
Bash: "is running commands...",
|
|
43
|
+
Glob: "is searching files...",
|
|
44
|
+
Grep: "is searching code...",
|
|
45
|
+
WebFetch: "is fetching web content...",
|
|
46
|
+
WebSearch: "is searching the web...",
|
|
47
|
+
Task: "is running a sub-agent...",
|
|
48
|
+
EnterPlanMode: "is planning...",
|
|
49
|
+
ExitPlanMode: "is finalizing the plan...",
|
|
50
|
+
TaskCreate: "is creating tasks...",
|
|
51
|
+
TaskUpdate: "is updating tasks...",
|
|
52
|
+
TodoWrite: "is updating tasks...",
|
|
53
|
+
NotebookEdit: "is editing a notebook...",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function toolTitle(toolName: string, toolInput: any): string {
|
|
57
|
+
try {
|
|
58
|
+
switch (toolName) {
|
|
59
|
+
case "Read":
|
|
60
|
+
return `Read ${toolInput.file_path || "file"}`;
|
|
61
|
+
case "Write":
|
|
62
|
+
return `Write ${toolInput.file_path || "file"}`;
|
|
63
|
+
case "Edit":
|
|
64
|
+
return `Edit ${toolInput.file_path || "file"}`;
|
|
65
|
+
case "Bash": {
|
|
66
|
+
const cmd = toolInput.command || "";
|
|
67
|
+
return `Run: ${cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd}`;
|
|
68
|
+
}
|
|
69
|
+
case "Glob":
|
|
70
|
+
return `Search: ${toolInput.pattern || "files"}`;
|
|
71
|
+
case "Grep":
|
|
72
|
+
return `Search: ${toolInput.pattern || "code"}`;
|
|
73
|
+
case "Task": {
|
|
74
|
+
const desc = toolInput.description || toolInput.subagent_type || "task";
|
|
75
|
+
return `Sub-agent: ${desc}`;
|
|
76
|
+
}
|
|
77
|
+
case "AskUserQuestion": {
|
|
78
|
+
const q = toolInput.questions?.[0]?.question;
|
|
79
|
+
return q ? `Question: ${q}` : "Asking a question...";
|
|
80
|
+
}
|
|
81
|
+
case "EnterPlanMode":
|
|
82
|
+
return "Entering plan mode";
|
|
83
|
+
case "ExitPlanMode":
|
|
84
|
+
return "Plan ready";
|
|
85
|
+
case "TaskCreate":
|
|
86
|
+
case "TodoWrite":
|
|
87
|
+
return `Create task: ${toolInput.subject || toolInput.description || "task"}`;
|
|
88
|
+
case "TaskUpdate":
|
|
89
|
+
return `Update task: ${toolInput.subject || toolInput.status || "task"}`;
|
|
90
|
+
default:
|
|
91
|
+
return `${toolName}`;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
return toolName;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_OUTPUT_LEN = 120;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract a brief output summary from a tool result for display on task cards.
|
|
102
|
+
* Returns null if there's nothing meaningful to show.
|
|
103
|
+
*/
|
|
104
|
+
function extractToolOutput(resultSummary: any, contentBlock: any): string | null {
|
|
105
|
+
// Try the top-level tool_use_result summary first (concise)
|
|
106
|
+
let text: string | null = null;
|
|
107
|
+
if (typeof resultSummary === "string" && resultSummary.length > 0) {
|
|
108
|
+
text = resultSummary;
|
|
109
|
+
} else if (resultSummary?.message) {
|
|
110
|
+
text = resultSummary.message;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fall back to the content block's content field
|
|
114
|
+
if (!text && contentBlock?.content) {
|
|
115
|
+
const raw = typeof contentBlock.content === "string"
|
|
116
|
+
? contentBlock.content
|
|
117
|
+
: JSON.stringify(contentBlock.content);
|
|
118
|
+
// Count lines for file/search results
|
|
119
|
+
const lines = raw.split("\n");
|
|
120
|
+
if (lines.length > 3) {
|
|
121
|
+
text = `${lines.length} lines`;
|
|
122
|
+
} else if (raw.length > 0 && raw.length <= MAX_OUTPUT_LEN) {
|
|
123
|
+
text = raw;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!text) return null;
|
|
128
|
+
// Strip "Error: " prefix noise from non-interactive tool stubs
|
|
129
|
+
if (text.startsWith("Error: ")) text = text.slice(7);
|
|
130
|
+
// Truncate
|
|
131
|
+
return text.length > MAX_OUTPUT_LEN ? text.slice(0, MAX_OUTPUT_LEN - 3) + "..." : text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function handleClaudeStream(opts: HandleClaudeStreamOpts): Promise<void> {
|
|
135
|
+
const {
|
|
136
|
+
channelId, threadTs, userText, userId, client,
|
|
137
|
+
spawnCwd, isResume, setStatus,
|
|
138
|
+
activeProcesses, cachedTeamId, botUserId,
|
|
139
|
+
} = opts;
|
|
140
|
+
let { sessionId } = opts;
|
|
141
|
+
|
|
142
|
+
// ── Thinking indicator (instant feedback) ──
|
|
143
|
+
await setStatus({
|
|
144
|
+
status: "is thinking...",
|
|
145
|
+
loading_messages: [
|
|
146
|
+
"Thinking...",
|
|
147
|
+
"Processing your request...",
|
|
148
|
+
"Working on it...",
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Stop button is posted lazily: after first stream content (so it appears
|
|
153
|
+
// below the streaming text) or before first fallback chat.update.
|
|
154
|
+
let stopMsgTs: string | undefined;
|
|
155
|
+
let stopBtnPromise: Promise<void>;
|
|
156
|
+
function ensureStopButton(): Promise<void> {
|
|
157
|
+
if (!stopBtnPromise) {
|
|
158
|
+
stopBtnPromise = client.chat.postMessage({
|
|
159
|
+
channel: channelId,
|
|
160
|
+
thread_ts: threadTs,
|
|
161
|
+
text: " ",
|
|
162
|
+
blocks: buildStopOnlyBlocks(threadTs),
|
|
163
|
+
}).then((res: any) => {
|
|
164
|
+
stopMsgTs = res.ts;
|
|
165
|
+
log(channelId, `Stop-button carrier posted: ts=${stopMsgTs}`);
|
|
166
|
+
}).catch((err: any) => {
|
|
167
|
+
logErr(channelId, `Failed to post stop button: ${err.message}`);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return stopBtnPromise;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Streamer ──────────────────────────────────────────────
|
|
174
|
+
// Assistant threads: created lazily (native setStatus provides instant feedback
|
|
175
|
+
// while we detect plan vs timeline mode from the first content event).
|
|
176
|
+
// Channel @mentions: created eagerly with a "Thinking" in_progress task
|
|
177
|
+
// (no native setStatus available, so the stream IS the thinking indicator).
|
|
178
|
+
let streamer: any = null;
|
|
179
|
+
let displayMode: "plan" | "timeline" | null = null;
|
|
180
|
+
let streamerActive = false;
|
|
181
|
+
let streamFailed = !cachedTeamId;
|
|
182
|
+
|
|
183
|
+
if (!cachedTeamId) {
|
|
184
|
+
log(channelId, `No cached team_id, using chat.update fallback`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function initStreamer(mode: "plan" | "timeline") {
|
|
188
|
+
if (streamer) return;
|
|
189
|
+
displayMode = mode;
|
|
190
|
+
try {
|
|
191
|
+
streamer = client.chatStream({
|
|
192
|
+
channel: channelId,
|
|
193
|
+
thread_ts: threadTs,
|
|
194
|
+
recipient_team_id: cachedTeamId,
|
|
195
|
+
recipient_user_id: userId,
|
|
196
|
+
task_display_mode: mode,
|
|
197
|
+
});
|
|
198
|
+
log(channelId, `Streamer created: task_display_mode=${mode}`);
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
logErr(channelId, `Streamer creation failed (${mode}): ${err.message}`);
|
|
201
|
+
streamFailed = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ensure the streamer exists (defaults to timeline if not yet created),
|
|
207
|
+
* then append chunks/text. Handles the appendChain serialization.
|
|
208
|
+
*/
|
|
209
|
+
function safeAppend(payload: any) {
|
|
210
|
+
if (streamFailed) return;
|
|
211
|
+
if (!streamer) initStreamer("timeline");
|
|
212
|
+
appendChain = appendChain.then(async () => {
|
|
213
|
+
if (!streamerActive) {
|
|
214
|
+
streamerActive = true;
|
|
215
|
+
log(channelId, `Streamer activated`);
|
|
216
|
+
}
|
|
217
|
+
await streamer.append(payload);
|
|
218
|
+
}).catch((err: any) => {
|
|
219
|
+
if (!streamFailed) {
|
|
220
|
+
logErr(channelId, `Stream append failed: ${err.message}`);
|
|
221
|
+
streamFailed = true;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Build Claude args ─────────────────────────────────────
|
|
227
|
+
const args = [
|
|
228
|
+
"-p", userText,
|
|
229
|
+
"--output-format", "stream-json",
|
|
230
|
+
"--verbose",
|
|
231
|
+
"--include-partial-messages",
|
|
232
|
+
"--dangerously-skip-permissions",
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// Append user-defined additional args from .env
|
|
236
|
+
const additionalArgs = (process.env.CLAUDE_ADDITIONAL_ARGS || "").trim();
|
|
237
|
+
if (additionalArgs) {
|
|
238
|
+
args.push(...additionalArgs.split(/\s+/));
|
|
239
|
+
log(channelId, `Additional claude args: ${additionalArgs}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (isResume) {
|
|
243
|
+
args.push("--resume", sessionId);
|
|
244
|
+
} else {
|
|
245
|
+
args.push("--session-id", sessionId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Inject team teachings
|
|
249
|
+
const teachings = getTeachings("default");
|
|
250
|
+
if (teachings.length > 0) {
|
|
251
|
+
const teachingText = teachings.map((t) => `- ${t.instruction}`).join("\n");
|
|
252
|
+
args.push("--append-system-prompt", `\nTeam conventions:\n${teachingText}`);
|
|
253
|
+
log(channelId, `Injecting ${teachings.length} teaching(s) via --append-system-prompt`);
|
|
254
|
+
} else {
|
|
255
|
+
log(channelId, `No teachings to inject`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
log(channelId, `Spawning claude: cwd=${spawnCwd} resume=${isResume}`);
|
|
259
|
+
|
|
260
|
+
// ── Spawn Claude process ──────────────────────────────────
|
|
261
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
262
|
+
delete env.CLAUDECODE;
|
|
263
|
+
|
|
264
|
+
// Inject ENV_* variables: ENV_ANTHROPIC_KEY=xxx → ANTHROPIC_KEY=xxx
|
|
265
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
266
|
+
if (key.startsWith("ENV_") && key.length > 4) {
|
|
267
|
+
env[key.slice(4)] = val;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Inject Slack context for MCP server tools
|
|
272
|
+
env.SLACK_CHANNEL_ID = channelId;
|
|
273
|
+
env.SLACK_USER_ID = userId;
|
|
274
|
+
if (botUserId) env.SLACK_BOT_USER_ID = botUserId;
|
|
275
|
+
|
|
276
|
+
const proc = spawn(CLAUDE_PATH, args, { env: env as NodeJS.ProcessEnv, cwd: spawnCwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
277
|
+
proc.stdin!.end();
|
|
278
|
+
activeProcesses.set(threadTs, proc);
|
|
279
|
+
|
|
280
|
+
let accumulatedText = "";
|
|
281
|
+
let appendChain = Promise.resolve();
|
|
282
|
+
let lastUpdateTime = 0;
|
|
283
|
+
let lastUpdatePromise = Promise.resolve();
|
|
284
|
+
let updateCount = 0;
|
|
285
|
+
let deltaCount = 0;
|
|
286
|
+
let jsonBuffer = "";
|
|
287
|
+
let stopped = false;
|
|
288
|
+
let done = false;
|
|
289
|
+
let resultData: any = null;
|
|
290
|
+
const startTime = Date.now();
|
|
291
|
+
|
|
292
|
+
// Tool tracking for agentic visualization
|
|
293
|
+
// Maps content_block index → tool info (including the tool_use_id for sub-agent correlation)
|
|
294
|
+
const activeTools = new Map<number, {
|
|
295
|
+
name: string;
|
|
296
|
+
inputJson: string;
|
|
297
|
+
taskId: string;
|
|
298
|
+
toolUseId: string;
|
|
299
|
+
}>();
|
|
300
|
+
let taskIdCounter = 0;
|
|
301
|
+
let thinkingTaskDone = false;
|
|
302
|
+
let planModeActive = false;
|
|
303
|
+
|
|
304
|
+
// Sub-agent tracking: maps a Task tool_use_id → its visual task info
|
|
305
|
+
const subAgentTasks = new Map<string, { description: string; taskId: string }>();
|
|
306
|
+
|
|
307
|
+
// Completed tool tracking: maps tool_use_id → task info so we can update
|
|
308
|
+
// the task card with output/sources/error when the tool result arrives later.
|
|
309
|
+
const completedTools = new Map<string, { taskId: string; name: string; title: string }>();
|
|
310
|
+
|
|
311
|
+
log(channelId, `Claude process started: pid=${proc.pid}, session=${sessionId}, resume=${isResume}, streaming=${!streamFailed}`);
|
|
312
|
+
|
|
313
|
+
// Clear debug file for this run
|
|
314
|
+
if (STREAM_DEBUG) {
|
|
315
|
+
writeFileSync(STREAM_DEBUG_FILE, "");
|
|
316
|
+
log(channelId, `[STREAM_DEBUG] Dumping raw NDJSON to ${STREAM_DEBUG_FILE}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
proc.stdout!.on("data", (chunk: Buffer) => {
|
|
320
|
+
const raw = chunk.toString();
|
|
321
|
+
jsonBuffer += raw;
|
|
322
|
+
const lines = jsonBuffer.split("\n");
|
|
323
|
+
jsonBuffer = lines.pop()!;
|
|
324
|
+
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
if (!line.trim()) continue;
|
|
327
|
+
|
|
328
|
+
// Dump every raw line to debug file
|
|
329
|
+
if (STREAM_DEBUG) {
|
|
330
|
+
try { appendFileSync(STREAM_DEBUG_FILE, line + "\n"); } catch {}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const data = JSON.parse(line);
|
|
335
|
+
|
|
336
|
+
// ── system messages ────────────────────────────────
|
|
337
|
+
if (data.type === "system") {
|
|
338
|
+
log(channelId, `stream: type=system subtype=${data.subtype} session_id=${data.session_id} model=${data.model || "n/a"}`);
|
|
339
|
+
if (data.subtype === "init" && data.session_id) {
|
|
340
|
+
const oldId = sessionId;
|
|
341
|
+
sessionId = data.session_id;
|
|
342
|
+
upsertSession(threadTs, data.session_id);
|
|
343
|
+
log(channelId, `Session ID updated: ${oldId} -> ${data.session_id}`);
|
|
344
|
+
} else if (data.subtype === "status") {
|
|
345
|
+
// Track permission mode changes (plan mode entry/exit)
|
|
346
|
+
if (data.permissionMode === "plan") {
|
|
347
|
+
planModeActive = true;
|
|
348
|
+
log(channelId, `stream: plan mode activated (permissionMode=plan)`);
|
|
349
|
+
} else if (planModeActive && data.permissionMode !== "plan") {
|
|
350
|
+
planModeActive = false;
|
|
351
|
+
log(channelId, `stream: plan mode deactivated (permissionMode=${data.permissionMode})`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── stream_event (raw Claude API events) ──────────
|
|
356
|
+
} else if (data.type === "stream_event") {
|
|
357
|
+
const evt = data.event;
|
|
358
|
+
const parentId = data.parent_tool_use_id;
|
|
359
|
+
|
|
360
|
+
// ── Sub-agent stream events: update parent task details ──
|
|
361
|
+
if (parentId && subAgentTasks.has(parentId)) {
|
|
362
|
+
// Sub-agent events are typically complete messages, but if any
|
|
363
|
+
// stream_events leak through with a parent_tool_use_id, log them.
|
|
364
|
+
if (evt?.type === "content_block_start" && evt.content_block?.type === "tool_use") {
|
|
365
|
+
const subTool = evt.content_block.name;
|
|
366
|
+
const parentTask = subAgentTasks.get(parentId)!;
|
|
367
|
+
const detail = TOOL_STATUS_MAP[subTool] || `Using ${subTool}...`;
|
|
368
|
+
safeAppend({
|
|
369
|
+
chunks: [{
|
|
370
|
+
type: "task_update",
|
|
371
|
+
id: parentTask.taskId,
|
|
372
|
+
title: parentTask.description,
|
|
373
|
+
status: "in_progress" as const,
|
|
374
|
+
details: detail,
|
|
375
|
+
}],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
continue; // don't process sub-agent stream_events as top-level
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (evt?.type === "message_start") {
|
|
382
|
+
log(channelId, `stream: message_start model=${evt.message?.model} id=${evt.message?.id}`);
|
|
383
|
+
|
|
384
|
+
} else if (evt?.type === "content_block_start") {
|
|
385
|
+
const blockType = evt.content_block?.type;
|
|
386
|
+
log(channelId, `stream: content_block_start index=${evt.index} type=${blockType}`);
|
|
387
|
+
|
|
388
|
+
// ── Plan mode detection: create plan streamer before any other appends ──
|
|
389
|
+
if (blockType === "tool_use" && evt.content_block.name === "EnterPlanMode" && !streamer) {
|
|
390
|
+
initStreamer("plan");
|
|
391
|
+
safeAppend({
|
|
392
|
+
chunks: [{ type: "plan_update", title: "Planning..." }],
|
|
393
|
+
});
|
|
394
|
+
log(channelId, `Plan mode: created plan streamer with plan_update title`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Ensure streamer exists (defaults to timeline) ──
|
|
398
|
+
// Skip for thinking blocks — no visual output, and creating the
|
|
399
|
+
// streamer (chat.startStream) clears the native setStatus indicator.
|
|
400
|
+
if (!streamer && !streamFailed && blockType !== "thinking") {
|
|
401
|
+
initStreamer("timeline");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Complete the "Thinking..." task (timeline only) ──
|
|
405
|
+
// In timeline mode, we show a brief "Thinking" completed card.
|
|
406
|
+
// In plan mode, the plan_update title is the indicator.
|
|
407
|
+
// Skip for thinking blocks — the native setStatus indicator is still active.
|
|
408
|
+
if (!thinkingTaskDone && !streamFailed && blockType !== "thinking") {
|
|
409
|
+
thinkingTaskDone = true;
|
|
410
|
+
if (displayMode === "timeline") {
|
|
411
|
+
safeAppend({
|
|
412
|
+
chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" as const }],
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── Track tool_use blocks for agentic visualization ──
|
|
418
|
+
if (blockType === "tool_use") {
|
|
419
|
+
const toolName = evt.content_block.name;
|
|
420
|
+
const toolUseId = evt.content_block.id || "";
|
|
421
|
+
const taskId = `task_${++taskIdCounter}`;
|
|
422
|
+
activeTools.set(evt.index, { name: toolName, inputJson: "", taskId, toolUseId });
|
|
423
|
+
|
|
424
|
+
// Update Slack status bar
|
|
425
|
+
const statusMsg = TOOL_STATUS_MAP[toolName] || `is using ${toolName}...`;
|
|
426
|
+
setStatus(statusMsg).catch(() => {});
|
|
427
|
+
log(channelId, `Tool start: ${toolName} (index=${evt.index}, taskId=${taskId}, toolUseId=${toolUseId})`);
|
|
428
|
+
|
|
429
|
+
// Emit in-progress task chunk (skip hidden/meta tools)
|
|
430
|
+
if (!HIDDEN_TOOLS.has(toolName)) {
|
|
431
|
+
safeAppend({
|
|
432
|
+
chunks: [{
|
|
433
|
+
type: "task_update",
|
|
434
|
+
id: taskId,
|
|
435
|
+
title: `Using ${toolName}...`,
|
|
436
|
+
status: "in_progress" as const,
|
|
437
|
+
}],
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Thinking block: show indicator ──
|
|
443
|
+
if (blockType === "thinking") {
|
|
444
|
+
log(channelId, `stream: thinking block started (index=${evt.index})`);
|
|
445
|
+
if (displayMode === "plan") {
|
|
446
|
+
setStatus("is planning...").catch(() => {});
|
|
447
|
+
} else {
|
|
448
|
+
setStatus("is thinking...").catch(() => {});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
} else if (evt?.type === "content_block_delta") {
|
|
453
|
+
if (evt?.delta?.type === "text_delta") {
|
|
454
|
+
deltaCount++;
|
|
455
|
+
const deltaText = evt.delta.text;
|
|
456
|
+
accumulatedText += deltaText;
|
|
457
|
+
|
|
458
|
+
if (deltaCount === 1 || deltaCount % 10 === 0) {
|
|
459
|
+
log(channelId, `stream: text_delta #${deltaCount}, accumulated=${accumulatedText.length} chars`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Ensure streamer + mark thinking done on first text
|
|
463
|
+
if (!streamer && !streamFailed) initStreamer("timeline");
|
|
464
|
+
if (!thinkingTaskDone && !streamFailed) {
|
|
465
|
+
thinkingTaskDone = true;
|
|
466
|
+
if (displayMode === "timeline") {
|
|
467
|
+
safeAppend({
|
|
468
|
+
chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" as const }],
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Streaming path: use chatStream
|
|
474
|
+
if (!streamFailed) {
|
|
475
|
+
appendChain = appendChain.then(async () => {
|
|
476
|
+
if (!streamerActive) {
|
|
477
|
+
streamerActive = true;
|
|
478
|
+
log(channelId, `Streamer activated: first text append`);
|
|
479
|
+
}
|
|
480
|
+
await streamer.append({ markdown_text: deltaText });
|
|
481
|
+
ensureStopButton();
|
|
482
|
+
}).catch((err: any) => {
|
|
483
|
+
if (!streamFailed) {
|
|
484
|
+
logErr(channelId, `Streaming failed, falling back to chat.update: ${err.message}`);
|
|
485
|
+
streamFailed = true;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Fallback path: throttled chat.update
|
|
491
|
+
if (streamFailed) {
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
if (!done && now - lastUpdateTime >= UPDATE_INTERVAL_MS) {
|
|
494
|
+
lastUpdateTime = now;
|
|
495
|
+
updateCount++;
|
|
496
|
+
log(channelId, `chat.update #${updateCount}: ${accumulatedText.length} chars`);
|
|
497
|
+
lastUpdatePromise = ensureStopButton().then(() => {
|
|
498
|
+
if (!stopMsgTs) return;
|
|
499
|
+
return client.chat.update({
|
|
500
|
+
channel: channelId,
|
|
501
|
+
ts: stopMsgTs,
|
|
502
|
+
text: accumulatedText,
|
|
503
|
+
blocks: buildBlocks(accumulatedText, threadTs, true),
|
|
504
|
+
});
|
|
505
|
+
}).catch((err: any) => logErr(channelId, `chat.update failed: ${err.message}`));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else if (evt?.delta?.type === "input_json_delta") {
|
|
509
|
+
// Accumulate tool input JSON for title extraction
|
|
510
|
+
const tool = activeTools.get(evt.index);
|
|
511
|
+
if (tool) {
|
|
512
|
+
tool.inputJson += evt.delta.partial_json || "";
|
|
513
|
+
}
|
|
514
|
+
} else if (evt?.delta?.type === "thinking_delta") {
|
|
515
|
+
// Extended thinking — no visual output, just log occasionally
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
} else if (evt?.type === "content_block_stop") {
|
|
519
|
+
log(channelId, `stream: content_block_stop index=${evt.index}`);
|
|
520
|
+
|
|
521
|
+
// Complete tool task chunk
|
|
522
|
+
const tool = activeTools.get(evt.index);
|
|
523
|
+
if (tool) {
|
|
524
|
+
activeTools.delete(evt.index);
|
|
525
|
+
|
|
526
|
+
let parsedInput: any = {};
|
|
527
|
+
try { parsedInput = JSON.parse(tool.inputJson); } catch {}
|
|
528
|
+
const title = toolTitle(tool.name, parsedInput);
|
|
529
|
+
log(channelId, `Tool complete: ${tool.name} -> "${title}"`);
|
|
530
|
+
|
|
531
|
+
// ── Special handling: AskUserQuestion ──
|
|
532
|
+
// Render the question + options as visible content so the user
|
|
533
|
+
// sees what Claude wanted to ask (the tool itself errors in
|
|
534
|
+
// non-interactive mode, but the question is still valuable).
|
|
535
|
+
if (tool.name === "AskUserQuestion") {
|
|
536
|
+
const questions = parsedInput.questions || [];
|
|
537
|
+
const parts: string[] = [];
|
|
538
|
+
for (const q of questions) {
|
|
539
|
+
if (q.question) parts.push(`> *${q.question}*`);
|
|
540
|
+
for (const opt of q.options || []) {
|
|
541
|
+
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
542
|
+
parts.push(`> • ${opt.label}${desc}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (parts.length > 0) {
|
|
546
|
+
safeAppend({ markdown_text: "\n" + parts.join("\n") + "\n\n" });
|
|
547
|
+
}
|
|
548
|
+
// Mark the task complete with the question as title
|
|
549
|
+
safeAppend({
|
|
550
|
+
chunks: [{
|
|
551
|
+
type: "task_update",
|
|
552
|
+
id: tool.taskId,
|
|
553
|
+
title,
|
|
554
|
+
status: "complete" as const,
|
|
555
|
+
}],
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ── Special handling: Task (sub-agent) ──
|
|
559
|
+
} else if (tool.name === "Task") {
|
|
560
|
+
const desc = parsedInput.description || parsedInput.subagent_type || "Sub-agent";
|
|
561
|
+
subAgentTasks.set(tool.toolUseId, { description: `Sub-agent: ${desc}`, taskId: tool.taskId });
|
|
562
|
+
log(channelId, `Sub-agent registered: toolUseId=${tool.toolUseId} desc="${desc}"`);
|
|
563
|
+
// Don't mark complete yet — it completes when the sub-agent finishes
|
|
564
|
+
// (we'll get a type=user tool_result for this toolUseId)
|
|
565
|
+
} else if (HIDDEN_TOOLS.has(tool.name)) {
|
|
566
|
+
// Hidden tools (EnterPlanMode, ExitPlanMode):
|
|
567
|
+
// no task_update emitted on start, so nothing to complete.
|
|
568
|
+
// But update plan title on ExitPlanMode
|
|
569
|
+
if (tool.name === "ExitPlanMode" && displayMode === "plan") {
|
|
570
|
+
safeAppend({
|
|
571
|
+
chunks: [{ type: "plan_update", title: "Plan ready" }],
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
// Normal tool: emit complete task chunk
|
|
576
|
+
// Attach sources for web tools (WebFetch, WebSearch)
|
|
577
|
+
const sources: { type: "url"; url: string; text: string }[] = [];
|
|
578
|
+
if (tool.name === "WebFetch" && parsedInput.url) {
|
|
579
|
+
try {
|
|
580
|
+
const hostname = new URL(parsedInput.url).hostname;
|
|
581
|
+
sources.push({ type: "url", url: parsedInput.url, text: hostname });
|
|
582
|
+
} catch {
|
|
583
|
+
sources.push({ type: "url", url: parsedInput.url, text: parsedInput.url });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
safeAppend({
|
|
588
|
+
chunks: [{
|
|
589
|
+
type: "task_update",
|
|
590
|
+
id: tool.taskId,
|
|
591
|
+
title,
|
|
592
|
+
status: "complete" as const,
|
|
593
|
+
...(sources.length > 0 ? { sources } : {}),
|
|
594
|
+
}],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Register for later output/error update when tool result arrives
|
|
598
|
+
completedTools.set(tool.toolUseId, { taskId: tool.taskId, name: tool.name, title });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Reset status to thinking/planning
|
|
602
|
+
setStatus(planModeActive ? "is planning..." : "is thinking...").catch(() => {});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
} else if (evt?.type === "message_delta") {
|
|
606
|
+
log(channelId, `stream: message_delta stop_reason=${evt.delta?.stop_reason}`);
|
|
607
|
+
} else if (evt?.type === "message_stop") {
|
|
608
|
+
log(channelId, `stream: message_stop`);
|
|
609
|
+
} else {
|
|
610
|
+
log(channelId, `stream: stream_event type=${evt?.type}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── assistant messages (complete turn) ────────────
|
|
614
|
+
} else if (data.type === "assistant") {
|
|
615
|
+
const parentId = data.parent_tool_use_id;
|
|
616
|
+
const content = data.message?.content;
|
|
617
|
+
const model = data.message?.model || "unknown";
|
|
618
|
+
|
|
619
|
+
if (parentId && subAgentTasks.has(parentId)) {
|
|
620
|
+
// Sub-agent assistant message: extract tool calls to update details
|
|
621
|
+
const parentTask = subAgentTasks.get(parentId)!;
|
|
622
|
+
const toolCalls = (content || []).filter((c: any) => c.type === "tool_use");
|
|
623
|
+
if (toolCalls.length > 0) {
|
|
624
|
+
const toolNames = toolCalls.map((t: any) => t.name).join(", ");
|
|
625
|
+
log(channelId, `stream: sub-agent (${model}) tools: ${toolNames} [parent=${parentId}]`);
|
|
626
|
+
// Update the sub-agent task's details with what it's doing
|
|
627
|
+
const lastTool = toolCalls[toolCalls.length - 1];
|
|
628
|
+
let detail = TOOL_STATUS_MAP[lastTool.name] || `Using ${lastTool.name}...`;
|
|
629
|
+
// Try to get a more specific detail from the tool input
|
|
630
|
+
try {
|
|
631
|
+
if (lastTool.name === "Read" && lastTool.input?.file_path) {
|
|
632
|
+
detail = `Reading ${lastTool.input.file_path.split("/").pop()}`;
|
|
633
|
+
} else if (lastTool.name === "Grep" && lastTool.input?.pattern) {
|
|
634
|
+
detail = `Searching: ${lastTool.input.pattern}`;
|
|
635
|
+
} else if (lastTool.name === "Glob" && lastTool.input?.pattern) {
|
|
636
|
+
detail = `Finding: ${lastTool.input.pattern}`;
|
|
637
|
+
} else if (lastTool.name === "Bash" && lastTool.input?.command) {
|
|
638
|
+
const cmd = lastTool.input.command;
|
|
639
|
+
detail = `Running: ${cmd.length > 40 ? cmd.slice(0, 37) + "..." : cmd}`;
|
|
640
|
+
}
|
|
641
|
+
} catch {}
|
|
642
|
+
safeAppend({
|
|
643
|
+
chunks: [{
|
|
644
|
+
type: "task_update",
|
|
645
|
+
id: parentTask.taskId,
|
|
646
|
+
title: parentTask.description,
|
|
647
|
+
status: "in_progress" as const,
|
|
648
|
+
details: detail,
|
|
649
|
+
}],
|
|
650
|
+
});
|
|
651
|
+
} else {
|
|
652
|
+
const textLen = content?.[0]?.text?.length || 0;
|
|
653
|
+
log(channelId, `stream: sub-agent (${model}) text response, len=${textLen} [parent=${parentId}]`);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
const textLen = content?.[0]?.text?.length || 0;
|
|
657
|
+
log(channelId, `stream: assistant message (${model}), content_length=${textLen} chars`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ── user messages (tool results) ──────────────────
|
|
661
|
+
} else if (data.type === "user") {
|
|
662
|
+
const parentId = data.parent_tool_use_id;
|
|
663
|
+
const resultSummary = data.tool_use_result;
|
|
664
|
+
const content = data.message?.content;
|
|
665
|
+
|
|
666
|
+
if (parentId && subAgentTasks.has(parentId)) {
|
|
667
|
+
// Sub-agent tool result: update details
|
|
668
|
+
const parentTask = subAgentTasks.get(parentId)!;
|
|
669
|
+
// Check if this is the sub-agent's prompt (first user message) or a tool result
|
|
670
|
+
const firstBlock = content?.[0];
|
|
671
|
+
if (firstBlock?.type === "tool_result") {
|
|
672
|
+
log(channelId, `stream: sub-agent tool result [parent=${parentId}]`);
|
|
673
|
+
} else {
|
|
674
|
+
log(channelId, `stream: sub-agent prompt delivered [parent=${parentId}]`);
|
|
675
|
+
}
|
|
676
|
+
} else if (parentId === null || parentId === undefined) {
|
|
677
|
+
// Top-level tool result
|
|
678
|
+
const firstBlock = content?.[0];
|
|
679
|
+
const toolUseId = firstBlock?.tool_use_id;
|
|
680
|
+
|
|
681
|
+
// Check if this completes a sub-agent Task
|
|
682
|
+
if (toolUseId && subAgentTasks.has(toolUseId)) {
|
|
683
|
+
const subTask = subAgentTasks.get(toolUseId)!;
|
|
684
|
+
// Extract a brief output summary from the result
|
|
685
|
+
const subOutput = extractToolOutput(resultSummary, firstBlock);
|
|
686
|
+
log(channelId, `stream: sub-agent completed: ${subTask.description} [toolUseId=${toolUseId}]`);
|
|
687
|
+
safeAppend({
|
|
688
|
+
chunks: [{
|
|
689
|
+
type: "task_update",
|
|
690
|
+
id: subTask.taskId,
|
|
691
|
+
title: subTask.description,
|
|
692
|
+
status: "complete" as const,
|
|
693
|
+
details: undefined,
|
|
694
|
+
...(subOutput ? { output: subOutput } : {}),
|
|
695
|
+
}],
|
|
696
|
+
});
|
|
697
|
+
subAgentTasks.delete(toolUseId);
|
|
698
|
+
|
|
699
|
+
// Update a completed tool's task card with output or error status
|
|
700
|
+
} else if (toolUseId && completedTools.has(toolUseId)) {
|
|
701
|
+
const completed = completedTools.get(toolUseId)!;
|
|
702
|
+
completedTools.delete(toolUseId);
|
|
703
|
+
|
|
704
|
+
const isError = firstBlock?.is_error === true;
|
|
705
|
+
const output = extractToolOutput(resultSummary, firstBlock);
|
|
706
|
+
|
|
707
|
+
// Extract sources from WebSearch results (URLs in the content)
|
|
708
|
+
const sources: { type: "url"; url: string; text: string }[] = [];
|
|
709
|
+
if (completed.name === "WebSearch" && firstBlock?.content) {
|
|
710
|
+
const raw = typeof firstBlock.content === "string" ? firstBlock.content : "";
|
|
711
|
+
const urlRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
712
|
+
let m: RegExpExecArray | null;
|
|
713
|
+
while ((m = urlRegex.exec(raw)) !== null && sources.length < 4) {
|
|
714
|
+
sources.push({ type: "url", url: m[2], text: m[1] });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (isError || output || sources.length > 0) {
|
|
719
|
+
log(channelId, `stream: tool result update taskId=${completed.taskId} error=${isError} sources=${sources.length}${output ? ` output="${output.substring(0, 60)}"` : ""}`);
|
|
720
|
+
safeAppend({
|
|
721
|
+
chunks: [{
|
|
722
|
+
type: "task_update",
|
|
723
|
+
id: completed.taskId,
|
|
724
|
+
title: completed.title,
|
|
725
|
+
status: isError ? "error" as const : "complete" as const,
|
|
726
|
+
...(output ? { output } : {}),
|
|
727
|
+
...(sources.length > 0 ? { sources } : {}),
|
|
728
|
+
}],
|
|
729
|
+
});
|
|
730
|
+
} else {
|
|
731
|
+
log(channelId, `stream: tool result (no output) [toolUseId=${toolUseId}]`);
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
// Untracked tool result — just log
|
|
735
|
+
const summary = typeof resultSummary === "string"
|
|
736
|
+
? resultSummary
|
|
737
|
+
: resultSummary?.message || "";
|
|
738
|
+
log(channelId, `stream: tool result${summary ? `: ${summary.substring(0, 80)}` : ""}`);
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
log(channelId, `stream: user message (parent=${parentId})`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ── result (final) ────────────────────────────────
|
|
745
|
+
} else if (data.type === "result") {
|
|
746
|
+
resultData = data;
|
|
747
|
+
const elapsed = Date.now() - startTime;
|
|
748
|
+
log(channelId, `stream: result subtype=${data.subtype} is_error=${data.is_error} duration_ms=${data.duration_ms} turns=${data.num_turns} cost=$${data.total_cost_usd?.toFixed(4)} session=${data.session_id}`);
|
|
749
|
+
if (data.is_error && data.result) {
|
|
750
|
+
logErr(channelId, `stream: error detail: ${typeof data.result === "string" ? data.result : JSON.stringify(data.result)}`);
|
|
751
|
+
}
|
|
752
|
+
log(channelId, `stream: total deltas=${deltaCount}, slack updates=${updateCount}, wall_time=${elapsed}ms`);
|
|
753
|
+
} else {
|
|
754
|
+
log(channelId, `stream: unknown type=${data.type}`);
|
|
755
|
+
}
|
|
756
|
+
} catch (err: any) {
|
|
757
|
+
logErr(channelId, `Failed to parse stream line: ${err.message} — raw: ${line.substring(0, 200)}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
proc.stderr!.on("data", (chunk: Buffer) => {
|
|
763
|
+
logErr(channelId, `claude stderr: ${chunk.toString().trim()}`);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
proc.on("error", (err) => {
|
|
767
|
+
logErr(channelId, `claude process error: ${err.message}`);
|
|
768
|
+
activeProcesses.delete(threadTs);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
return new Promise<void>((resolve) => {
|
|
772
|
+
proc.on("close", async (code, signal) => {
|
|
773
|
+
done = true;
|
|
774
|
+
activeProcesses.delete(threadTs);
|
|
775
|
+
stopped = signal === "SIGTERM";
|
|
776
|
+
const elapsed = Date.now() - startTime;
|
|
777
|
+
|
|
778
|
+
log(channelId, `Claude process exited: code=${code} signal=${signal} pid=${proc.pid} elapsed=${elapsed}ms stopped=${stopped}`);
|
|
779
|
+
log(channelId, `Final stats: deltas=${deltaCount}, slack_updates=${updateCount}, text_length=${accumulatedText.length}, streaming=${streamerActive}, mode=${displayMode}`);
|
|
780
|
+
|
|
781
|
+
// ── Usage logging ──────────────────────────────────
|
|
782
|
+
if (resultData) {
|
|
783
|
+
try {
|
|
784
|
+
addUsageLog(
|
|
785
|
+
threadTs, userId,
|
|
786
|
+
resultData.model || null,
|
|
787
|
+
resultData.input_tokens || 0,
|
|
788
|
+
resultData.output_tokens || 0,
|
|
789
|
+
resultData.total_cost_usd || 0,
|
|
790
|
+
resultData.duration_ms || elapsed,
|
|
791
|
+
resultData.num_turns || 0,
|
|
792
|
+
);
|
|
793
|
+
log(channelId, `Usage logged: cost=$${(resultData.total_cost_usd || 0).toFixed(4)} turns=${resultData.num_turns}`);
|
|
794
|
+
} catch (err: any) {
|
|
795
|
+
logErr(channelId, `Failed to log usage: ${err.message}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ── Mark any remaining sub-agent tasks as complete ──
|
|
800
|
+
for (const [id, sub] of subAgentTasks) {
|
|
801
|
+
safeAppend({
|
|
802
|
+
chunks: [{
|
|
803
|
+
type: "task_update",
|
|
804
|
+
id: sub.taskId,
|
|
805
|
+
title: sub.description,
|
|
806
|
+
status: "complete" as const,
|
|
807
|
+
}],
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
subAgentTasks.clear();
|
|
811
|
+
|
|
812
|
+
// ── Finalize streaming or fallback ─────────────────
|
|
813
|
+
const finalizationBlocks = [buildFeedbackBlock(threadTs), buildDisclaimerBlock()];
|
|
814
|
+
|
|
815
|
+
log(channelId, `Finalize path: streamFailed=${streamFailed} streamerActive=${streamerActive}`);
|
|
816
|
+
if (!streamFailed && streamerActive) {
|
|
817
|
+
await appendChain;
|
|
818
|
+
|
|
819
|
+
// Complete the thinking indicator if it never got resolved (timeline only)
|
|
820
|
+
if (!thinkingTaskDone && displayMode === "timeline") {
|
|
821
|
+
thinkingTaskDone = true;
|
|
822
|
+
await streamer.append({
|
|
823
|
+
chunks: [{ type: "task_update", id: "thinking", title: "Thinking", status: "complete" }],
|
|
824
|
+
}).catch(() => {});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (stopped) {
|
|
828
|
+
await streamer.append({ markdown_text: "\n\n_Stopped by user._" }).catch(() => {});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Finalize stream as a durable message with feedback/disclaimer blocks
|
|
832
|
+
try {
|
|
833
|
+
await streamer.stop({ blocks: finalizationBlocks });
|
|
834
|
+
log(channelId, `Stream finalized with blocks (${accumulatedText.length} chars, mode=${displayMode})`);
|
|
835
|
+
} catch (err: any) {
|
|
836
|
+
logErr(channelId, `streamer.stop failed: ${err.message}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Delete only the stop-button carrier
|
|
840
|
+
if (stopMsgTs) {
|
|
841
|
+
await client.chat.delete({ channel: channelId, ts: stopMsgTs }).catch((err: any) => {
|
|
842
|
+
logErr(channelId, `Failed to delete stop button carrier: ${err.message}`);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
} else if (streamer && !streamerActive) {
|
|
846
|
+
// Streamer was created but never activated (no content appended)
|
|
847
|
+
const text = stopped
|
|
848
|
+
? "_Stopped by user._"
|
|
849
|
+
: code !== 0 ? "Something went wrong." : "No response.";
|
|
850
|
+
log(channelId, `No text produced, final message: "${text}"`);
|
|
851
|
+
|
|
852
|
+
// Try to use the streamer for a clean message
|
|
853
|
+
try {
|
|
854
|
+
await streamer.append({ markdown_text: text });
|
|
855
|
+
await streamer.stop({ blocks: finalizationBlocks });
|
|
856
|
+
} catch {
|
|
857
|
+
// Streamer failed, fall back to postMessage
|
|
858
|
+
await client.chat.postMessage({
|
|
859
|
+
channel: channelId,
|
|
860
|
+
thread_ts: threadTs,
|
|
861
|
+
text,
|
|
862
|
+
blocks: [...buildBlocks(text, threadTs, false), ...finalizationBlocks],
|
|
863
|
+
}).catch((err: any) => logErr(channelId, `Final postMessage failed: ${err.message}`));
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
// Fallback: chat.update mode (no streamer or stream failed)
|
|
867
|
+
let finalText: string;
|
|
868
|
+
if (stopped) {
|
|
869
|
+
finalText = accumulatedText
|
|
870
|
+
? accumulatedText + "\n\n_Stopped by user._"
|
|
871
|
+
: "_Stopped by user._";
|
|
872
|
+
} else {
|
|
873
|
+
finalText = accumulatedText || (code !== 0 ? "Something went wrong." : "No response.");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
await lastUpdatePromise;
|
|
877
|
+
await ensureStopButton();
|
|
878
|
+
|
|
879
|
+
log(channelId, `Sending final chat.update (${finalText.length} chars)`);
|
|
880
|
+
if (stopMsgTs) {
|
|
881
|
+
await client.chat
|
|
882
|
+
.update({
|
|
883
|
+
channel: channelId,
|
|
884
|
+
ts: stopMsgTs,
|
|
885
|
+
text: finalText,
|
|
886
|
+
blocks: [...buildBlocks(finalText, threadTs, false), ...finalizationBlocks],
|
|
887
|
+
})
|
|
888
|
+
.catch((err: any) => logErr(channelId, `Final chat.update failed: ${err.message}`));
|
|
889
|
+
} else {
|
|
890
|
+
await client.chat
|
|
891
|
+
.postMessage({
|
|
892
|
+
channel: channelId,
|
|
893
|
+
thread_ts: threadTs,
|
|
894
|
+
text: finalText,
|
|
895
|
+
blocks: [...buildBlocks(finalText, threadTs, false), ...finalizationBlocks],
|
|
896
|
+
})
|
|
897
|
+
.catch((err: any) => logErr(channelId, `Final postMessage failed: ${err.message}`));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Clear Assistant status indicator
|
|
902
|
+
await setStatus("").catch(() => {});
|
|
903
|
+
|
|
904
|
+
log(channelId, `Done processing message from user=${userId}`);
|
|
905
|
+
resolve();
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}
|