Skip to content

Commit 64a2982

Browse files
authored
Merge pull request #36 from odefun/ode_1770374020.800729
fix: render Claude live status from raw stream events
2 parents 4739f54 + 48a982e commit 64a2982

File tree

8 files changed

+932
-489
lines changed

8 files changed

+932
-489
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ode",
3-
"version": "0.0.36",
3+
"version": "0.0.37",
44
"description": "Ode - OpenCode chat controller for Slack",
55
"module": "packages/core/index.ts",
66
"type": "module",

packages/agents/claude/client.ts

Lines changed: 14 additions & 339 deletions
Original file line numberDiff line numberDiff line change
@@ -38,71 +38,6 @@ type ClaudeJsonRecord = {
3838
session_id?: string;
3939
};
4040

41-
type SessionLikeEvent = {
42-
type: string;
43-
properties: Record<string, unknown>;
44-
};
45-
46-
type ClaudeToolState = {
47-
id: string;
48-
name: string;
49-
inputBuffer?: string;
50-
input?: Record<string, unknown>;
51-
};
52-
53-
function tryParseObject(input: string): Record<string, unknown> | null {
54-
const trimmed = input.trim();
55-
if (!trimmed) return null;
56-
try {
57-
const parsed = JSON.parse(trimmed) as unknown;
58-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
59-
? (parsed as Record<string, unknown>)
60-
: null;
61-
} catch {
62-
return null;
63-
}
64-
}
65-
66-
function extractSessionTitle(value: unknown): string | undefined {
67-
if (!value || typeof value !== "object") return undefined;
68-
const queue: unknown[] = [value];
69-
while (queue.length > 0) {
70-
const current = queue.shift();
71-
if (!current || typeof current !== "object") continue;
72-
73-
if (Array.isArray(current)) {
74-
for (const item of current) queue.push(item);
75-
continue;
76-
}
77-
78-
const record = current as Record<string, unknown>;
79-
const directTitle = record.title;
80-
if (typeof directTitle === "string") {
81-
const trimmed = directTitle.trim();
82-
if (trimmed && !trimmed.startsWith("New session")) {
83-
return trimmed;
84-
}
85-
}
86-
87-
const info = record.info;
88-
if (info && typeof info === "object" && !Array.isArray(info)) {
89-
const infoTitle = (info as Record<string, unknown>).title;
90-
if (typeof infoTitle === "string") {
91-
const trimmed = infoTitle.trim();
92-
if (trimmed && !trimmed.startsWith("New session")) {
93-
return trimmed;
94-
}
95-
}
96-
}
97-
98-
for (const nested of Object.values(record)) {
99-
if (nested && typeof nested === "object") queue.push(nested);
100-
}
101-
}
102-
103-
return undefined;
104-
}
105-
10641
function deriveSessionTitleFromPrompt(message: string): string | undefined {
10742
const normalized = message.replace(/\s+/g, " ").trim();
10843
if (!normalized) return undefined;
@@ -289,273 +224,22 @@ function publishSessionEvent(sessionId: string, event: unknown): void {
289224
}
290225
}
291226

292-
function statusFromClaudeRecord(
293-
record: ClaudeJsonRecord,
294-
toolByIndex: Map<number, ClaudeToolState>
295-
): string | null {
296-
if (record.type === "assistant") {
297-
return "Drafting response";
298-
}
299-
if (record.type === "result") {
300-
return record.is_error ? "Claude reported an error" : "Finalizing response";
301-
}
302-
if (record.type !== "stream_event" || !record.event?.type) {
303-
return null;
304-
}
305-
306-
switch (record.event.type) {
307-
case "message_start":
308-
return "Thinking";
309-
case "content_block_start": {
310-
const block = record.event.content_block;
311-
if (block?.type === "tool_use") {
312-
const toolName = typeof block.name === "string" ? block.name : "tool";
313-
return `Running tool: ${toolName}`;
314-
}
315-
if (block?.type === "thinking") {
316-
return "Thinking";
317-
}
318-
return "Drafting response";
319-
}
320-
case "content_block_delta": {
321-
const delta = record.event.delta;
322-
if (delta?.type === "text_delta") {
323-
return "Drafting response";
324-
}
325-
if (delta?.type === "input_json_delta") {
326-
const index = typeof record.event?.index === "number" ? record.event.index : -1;
327-
const tool = toolByIndex.get(index);
328-
return tool ? `Running tool: ${tool.name}` : "Running tool";
329-
}
330-
if (delta?.type === "thinking_delta") {
331-
return "Thinking";
332-
}
333-
return null;
334-
}
335-
case "content_block_stop": {
336-
const index = typeof record.event.index === "number" ? record.event.index : -1;
337-
const tool = toolByIndex.get(index);
338-
return tool ? `Finished tool: ${tool.name}` : "Finished step";
339-
}
340-
case "message_stop":
341-
return "Finalizing response";
342-
default:
343-
return null;
344-
}
345-
}
346-
347-
export function mapClaudeRecordToSessionEvents(
348-
record: unknown,
349-
fallbackSessionId: string,
350-
textByIndex: Map<number, string>,
351-
toolByIndex: Map<number, ClaudeToolState>,
352-
thinkingByIndex: Map<number, string>
353-
): SessionLikeEvent[] {
354-
const parsedRecord = record as ClaudeJsonRecord;
355-
const events: SessionLikeEvent[] = [];
356-
const sessionId = getRecordSessionId(parsedRecord, fallbackSessionId);
357-
const sessionTitle = extractSessionTitle(parsedRecord);
358-
if (sessionTitle) {
359-
events.push({
360-
type: "session.updated",
361-
properties: {
362-
sessionID: sessionId,
363-
info: {
364-
title: sessionTitle,
365-
},
366-
},
367-
});
368-
}
369-
const status = statusFromClaudeRecord(parsedRecord, toolByIndex);
370-
if (status) {
371-
events.push({
372-
type: "session.status",
373-
properties: {
374-
sessionID: sessionId,
375-
status,
376-
},
377-
});
378-
}
379-
380-
if (parsedRecord.type === "assistant") {
381-
const text = parsedRecord.message?.content
382-
?.filter((block) => block?.type === "text")
383-
.map((block) => block.text ?? "")
384-
.join("")
385-
.trim();
386-
if (text) {
387-
events.push({
388-
type: "message.part.updated",
389-
properties: {
390-
part: {
391-
type: "text",
392-
text,
393-
sessionID: sessionId,
394-
},
395-
},
396-
});
397-
}
398-
return events;
399-
}
400-
401-
if (parsedRecord.type !== "stream_event" || !parsedRecord.event?.type) {
402-
return events;
403-
}
404-
405-
const eventType = parsedRecord.event.type;
406-
const index = typeof parsedRecord.event.index === "number" ? parsedRecord.event.index : -1;
407-
408-
if (eventType === "content_block_start") {
409-
const contentBlock = parsedRecord.event.content_block;
410-
if (contentBlock?.type === "tool_use") {
411-
const id = typeof contentBlock.id === "string" ? contentBlock.id : `tool-${Date.now()}-${index}`;
412-
const name = typeof contentBlock.name === "string" ? contentBlock.name : "tool";
413-
const input =
414-
contentBlock && typeof contentBlock.input === "object"
415-
? (contentBlock.input as Record<string, unknown>)
416-
: {};
417-
toolByIndex.set(index, { id, name, input });
418-
events.push({
419-
type: "message.part.updated",
420-
properties: {
421-
part: {
422-
type: "tool",
423-
id,
424-
tool: name,
425-
sessionID: sessionId,
426-
state: {
427-
status: "running",
428-
input,
429-
},
430-
},
431-
},
432-
});
433-
}
434-
if (contentBlock?.type === "thinking") {
435-
const thinking = typeof contentBlock.thinking === "string" ? contentBlock.thinking : "";
436-
if (thinking) {
437-
thinkingByIndex.set(index, thinking);
438-
events.push({
439-
type: "message.part.updated",
440-
properties: {
441-
part: {
442-
type: "thinking",
443-
text: thinking,
444-
sessionID: sessionId,
445-
},
446-
},
447-
});
448-
}
449-
}
450-
return events;
451-
}
452-
453-
if (eventType === "content_block_delta") {
454-
const delta = parsedRecord.event.delta;
455-
if (delta?.type === "text_delta") {
456-
const chunk = typeof delta.text === "string" ? delta.text : "";
457-
if (!chunk) return events;
458-
const next = `${textByIndex.get(index) ?? ""}${chunk}`;
459-
textByIndex.set(index, next);
460-
events.push({
461-
type: "message.part.updated",
462-
properties: {
463-
part: {
464-
type: "text",
465-
text: next,
466-
sessionID: sessionId,
467-
},
468-
},
469-
});
470-
return events;
471-
}
472-
473-
if (delta?.type === "input_json_delta") {
474-
const tool = toolByIndex.get(index);
475-
if (!tool) return events;
476-
const chunk = typeof delta.partial_json === "string" ? delta.partial_json : "";
477-
if (chunk) {
478-
tool.inputBuffer = `${tool.inputBuffer ?? ""}${chunk}`;
479-
const parsedInput = tryParseObject(tool.inputBuffer);
480-
if (parsedInput) {
481-
tool.input = parsedInput;
482-
}
483-
}
484-
events.push({
485-
type: "message.part.updated",
486-
properties: {
487-
part: {
488-
type: "tool",
489-
id: tool.id,
490-
tool: tool.name,
491-
sessionID: sessionId,
492-
state: {
493-
status: "running",
494-
input: tool.input,
495-
},
496-
},
497-
},
498-
});
499-
}
500-
501-
if (delta?.type === "thinking_delta") {
502-
const chunk = typeof delta.thinking === "string" ? delta.thinking : "";
503-
if (!chunk) return events;
504-
const next = `${thinkingByIndex.get(index) ?? ""}${chunk}`;
505-
thinkingByIndex.set(index, next);
506-
events.push({
507-
type: "message.part.updated",
508-
properties: {
509-
part: {
510-
type: "thinking",
511-
text: next,
512-
sessionID: sessionId,
513-
},
514-
},
515-
});
516-
}
517-
return events;
518-
}
519-
520-
if (eventType === "content_block_stop") {
521-
const tool = toolByIndex.get(index);
522-
if (!tool) return events;
523-
events.push({
524-
type: "message.part.updated",
525-
properties: {
526-
part: {
527-
type: "tool",
528-
id: tool.id,
529-
tool: tool.name,
530-
sessionID: sessionId,
531-
state: {
532-
status: "completed",
533-
input: tool.input,
534-
},
535-
},
536-
},
537-
});
538-
}
539-
540-
return events;
541-
}
542-
543227
function publishClaudeRecordAsSessionEvents(
544228
record: ClaudeJsonRecord,
545-
fallbackSessionId: string,
546-
textByIndex: Map<number, string>,
547-
toolByIndex: Map<number, ClaudeToolState>,
548-
thinkingByIndex: Map<number, string>
229+
fallbackSessionId: string
549230
): void {
550-
for (const event of mapClaudeRecordToSessionEvents(
551-
record,
552-
fallbackSessionId,
553-
textByIndex,
554-
toolByIndex,
555-
thinkingByIndex
556-
)) {
557-
publishSessionEvent(getRecordSessionId(record, fallbackSessionId), event);
558-
}
231+
const sessionId = getRecordSessionId(record, fallbackSessionId);
232+
const rawType = typeof record.type === "string" && record.type.trim()
233+
? record.type.trim()
234+
: "unknown";
235+
publishSessionEvent(sessionId, {
236+
type: `claude.raw.${rawType}`,
237+
properties: {
238+
record,
239+
recordType: rawType,
240+
streamEventType: typeof record.event?.type === "string" ? record.event.type : undefined,
241+
},
242+
});
559243
}
560244

561245
function parseClaudeResult(output: string): {
@@ -795,22 +479,13 @@ export async function sendMessage(
795479
});
796480

797481
const envOverrides = sessionEnvironments.get(sessionId) ?? {};
798-
const textByIndex = new Map<number, string>();
799-
const toolByIndex = new Map<number, ClaudeToolState>();
800-
const thinkingByIndex = new Map<number, string>();
801482
const { output, permissionMode, command } = await runClaudeWithFallback(
802483
args,
803484
workingPath,
804485
envOverrides,
805486
entry,
806487
(record) => {
807-
publishClaudeRecordAsSessionEvents(
808-
record,
809-
sessionId,
810-
textByIndex,
811-
toolByIndex,
812-
thinkingByIndex
813-
);
488+
publishClaudeRecordAsSessionEvents(record, sessionId);
814489
}
815490
);
816491

0 commit comments

Comments
 (0)