Skip to content

Commit e7afd1f

Browse files
committed
fix(chat): restore user messages in claude session history
eac1b96 moved history parsing from the frontend into the daemon's `mapSDKMessage`, but the `user` branch only extracted tool_result blocks and silently dropped plain-text user content — the regression parseable from f104a8c. Chat panels resumed showing only agent output. Widen `mapSDKMessage` to `AgentEvent[]` and own the split in the mapper: a `user` SDK message now emits `message.user` for human text and/or `message` for tool_result blocks, in that order. `getHistory` iterates and suffixes ids with `-user`/`-tool` for stability. Matches the codex provider's mapper-owns-user-message pattern.
1 parent e72d8b2 commit e7afd1f

4 files changed

Lines changed: 476 additions & 192 deletions

File tree

packages/cli/src/providers/claude.ts

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ function mapToolResult(msg: SDKUserMessage): ContentBlock[] {
140140
return blocks;
141141
}
142142

143+
// User messages from the Claude SDK carry either tool_result blocks (assistant
144+
// attribution) or plain text (real human input — initial prompt or chat reply).
145+
// Extract the latter so history can surface it as a role:user message.
146+
function extractUserText(msg: SDKUserMessage): string {
147+
const content = msg.message.content;
148+
if (typeof content === "string") return content.trim();
149+
if (!Array.isArray(content)) return "";
150+
return content
151+
.filter((b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string")
152+
.map((b) => b.text)
153+
.join("\n")
154+
.trim();
155+
}
156+
143157
/**
144158
* Map SDK system task_* messages → our subtask.* events.
145159
* SDK subtypes: task_started | task_progress | task_notification.
@@ -173,8 +187,16 @@ function mapTaskSystemMessage(msg: any): AgentEvent | null {
173187
}
174188
}
175189

176-
/** Map a single SDK message to an AgentEvent (1:1, used for history fallback). */
177-
export function mapSDKMessage(msg: SDKMessage): AgentEvent | null {
190+
/**
191+
* Map a single SDK message to a list of AgentEvents (0..N).
192+
*
193+
* Most SDK message types produce a single event, but `user` messages can
194+
* contain both plain text (human input) and tool_result blocks (assistant
195+
* attribution). Those must emit two events in order — user text first, then
196+
* the tool_result-bearing message — so the UI can render human turn before
197+
* the tool output that follows it.
198+
*/
199+
export function mapSDKMessage(msg: SDKMessage): AgentEvent[] {
178200
switch (msg.type) {
179201
case "rate_limit_event": {
180202
const info = msg.rate_limit_info;
@@ -186,24 +208,26 @@ export function mapSDKMessage(msg: SDKMessage): AgentEvent | null {
186208
resetAt: info.overageResetsAt ? new Date(info.overageResetsAt * 1000).toISOString() : undefined,
187209
}
188210
: undefined;
189-
return {
190-
type: "turn.rate_limit",
191-
status: info.status,
192-
resetAt,
193-
rateLimitType: info.rateLimitType,
194-
isUsingOverage: info.isUsingOverage,
195-
overage,
196-
};
211+
return [
212+
{
213+
type: "turn.rate_limit",
214+
status: info.status,
215+
resetAt,
216+
rateLimitType: info.rateLimitType,
217+
isUsingOverage: info.isUsingOverage,
218+
overage,
219+
},
220+
];
197221
}
198222
if (info.status === "allowed_warning") {
199223
logger.warn(`Rate limit warning: ${info.rateLimitType} at ${((info.utilization ?? 0) * 100).toFixed(0)}%`);
200224
}
201-
return null;
225+
return [];
202226
}
203227

204228
case "assistant": {
205229
if (msg.error === "rate_limit") {
206-
return { type: "turn.rate_limit", status: "rejected", resetAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() };
230+
return [{ type: "turn.rate_limit", status: "rejected", resetAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }];
207231
}
208232
if (msg.error) {
209233
const contentText = (msg.message?.content ?? [])
@@ -212,28 +236,36 @@ export function mapSDKMessage(msg: SDKMessage): AgentEvent | null {
212236
.join(" ")
213237
.slice(0, 500);
214238
const detail = contentText || msg.error;
215-
return { type: "turn.error", code: msg.error, detail };
239+
return [{ type: "turn.error", code: msg.error, detail }];
216240
}
217241
const parentId = msg.parent_tool_use_id;
218242
const blocks = (msg.message.content ?? []).map((b) => mapContentBlock(b, parentId)).filter((b): b is ContentBlock => b !== null);
219-
return blocks.length > 0 ? { type: "message", blocks } : null;
243+
return blocks.length > 0 ? [{ type: "message", blocks }] : [];
220244
}
221245

222246
case "user": {
247+
// Order matters: the human text precedes any tool_result blocks from
248+
// the same SDK message so consumers render the user turn first.
249+
const events: AgentEvent[] = [];
250+
const userText = extractUserText(msg);
251+
if (userText) events.push({ type: "message.user", text: userText });
223252
const blocks = mapToolResult(msg);
224-
return blocks.length > 0 ? { type: "message", blocks } : null;
253+
if (blocks.length > 0) events.push({ type: "message", blocks });
254+
return events;
225255
}
226256

227257
case "result": {
228258
const text = msg.subtype === "success" ? msg.result : undefined;
229-
return { type: "turn.end", text, cost: msg.total_cost_usd || 0, usage: msg.usage as Record<string, any> };
259+
return [{ type: "turn.end", text, cost: msg.total_cost_usd || 0, usage: msg.usage as Record<string, any> }];
230260
}
231261

232-
case "system":
233-
return mapTaskSystemMessage(msg);
262+
case "system": {
263+
const event = mapTaskSystemMessage(msg);
264+
return event ? [event] : [];
265+
}
234266

235267
default:
236-
return null;
268+
return [];
237269
}
238270
}
239271

@@ -295,8 +327,7 @@ export function* mapSDKMessageStream(msg: SDKMessage, turnOpen: { value: boolean
295327
if (assistantMsg.error === "rate_limit" && rateLimitSeen?.value) {
296328
return;
297329
}
298-
const event = mapSDKMessage(msg);
299-
if (event) yield event;
330+
yield* mapSDKMessage(msg);
300331
return;
301332
}
302333

@@ -335,8 +366,7 @@ export function* mapSDKMessageStream(msg: SDKMessage, turnOpen: { value: boolean
335366
}
336367

337368
// Everything else (rate_limit_event, etc.) — delegate
338-
const event = mapSDKMessage(msg);
339-
if (event) {
369+
for (const event of mapSDKMessage(msg)) {
340370
if (event.type === "turn.rate_limit" && rateLimitSeen !== undefined) {
341371
rateLimitSeen.value = true;
342372
}
@@ -445,9 +475,15 @@ export const claudeProvider: AgentProvider = {
445475
let counter = 0;
446476
for (const msg of messages) {
447477
const sdkLike = { ...msg, message: msg.message } as unknown as SDKMessage;
448-
const event = mapSDKMessage(sdkLike);
449-
if (event) {
450-
events.push({ id: msg.uuid || `claude-hist-${++counter}`, event, timestamp: new Date().toISOString() });
478+
const baseId = msg.uuid || `claude-hist-${++counter}`;
479+
const timestamp = new Date().toISOString();
480+
// User SDK messages can expand into two events (message.user + message
481+
// with tool_result). Suffix their ids so both are stable even when only
482+
// one of the two is emitted.
483+
const isUser = sdkLike.type === "user";
484+
for (const event of mapSDKMessage(sdkLike)) {
485+
const id = isUser ? `${baseId}-${event.type === "message.user" ? "user" : "tool"}` : baseId;
486+
events.push({ id, event, timestamp });
451487
}
452488
}
453489
return events;

0 commit comments

Comments
 (0)