feat(chat): persist agent tool-chain across reload + nudge requester ack into My Chat (core#2636) #2637

Merged
devops-engineer merged 1 commits from feat/2636-decision-visible-and-tooltrace-persist into main 2026-06-12 10:57:13 +00:00
9 changed files with 180 additions and 33 deletions
+4
View File
@@ -12,6 +12,7 @@ import { AttachmentPreview } from "./chat/AttachmentPreview";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { ChatErrorBanner } from "./chat/ChatErrorBanner";
import { appendActivityLine } from "./chat/activityLog";
import { ToolTraceChips } from "./chat/ToolTraceChips";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useChatHistory } from "./chat/hooks/useChatHistory";
@@ -601,6 +602,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
))}
</div>
)}
{msg.role === "agent" && msg.toolTrace && msg.toolTrace.length > 0 && (
<ToolTraceChips trace={msg.toolTrace} />
)}
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/80" : "text-ink-mid"}`}>
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { ToolTraceEntry } from "./types";
/** Render a persisted agent tool-use chain (core#2636) as a collapsible
* list under the agent bubble — the rehydrated twin of the live progress
* feed, so the chain that scrolled by during the turn is still there
* after a reload. Collapsed by default (a long turn can run dozens of
* tools); the header shows the count and toggles.
*/
export function ToolTraceChips({ trace }: { trace: ToolTraceEntry[] }) {
const [open, setOpen] = useState(false);
if (!trace.length) return null;
const n = trace.length;
return (
<div className="mt-1.5 border-t border-line/60 dark:border-zinc-600/60 pt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 text-[10px] text-ink-mid hover:text-ink transition-colors"
aria-expanded={open}
>
<span className="font-mono">{open ? "▾" : "▸"}</span>
<span>
{n} tool{n === 1 ? "" : "s"} used
</span>
</button>
{open && (
<ul className="mt-1 space-y-0.5">
{trace.map((t, i) => (
<li
key={`${t.tool}-${i}`}
className="font-mono text-[10px] text-ink-mid leading-snug break-all"
>
🛠 {formatTool(t)}
</li>
))}
</ul>
)}
</div>
);
}
/** Mirror the live feed's "🛠 <tool>(…)" shape. The runtime already
* truncates `input` to 500 chars at capture; we trim it further for the
* inline chip and never render raw secrets (input is a stringified
* preview, not the live arg values). */
function formatTool(t: ToolTraceEntry): string {
const input = (t.input ?? "").trim();
if (!input) return `${t.tool}(…)`;
const preview = input.length > 60 ? `${input.slice(0, 60)}` : input;
return `${t.tool}(${preview})`;
}
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ToolTraceChips } from "../ToolTraceChips";
describe("ToolTraceChips (core#2636 tool-chain persistence)", () => {
it("renders nothing for an empty trace", () => {
const { container } = render(<ToolTraceChips trace={[]} />);
expect(container.firstChild).toBeNull();
});
it("shows a collapsed count and expands to the tool list on click", () => {
render(
<ToolTraceChips
trace={[
{ tool: "mcp__platform__create_request", input: "{}" },
{ tool: "Read", input: "/tmp/foo" },
]}
/>,
);
// Collapsed: count visible, individual tools hidden.
expect(screen.getByText("2 tools used")).toBeTruthy();
expect(screen.queryByText(/create_request/)).toBeNull();
fireEvent.click(screen.getByRole("button"));
expect(screen.getByText(/mcp__platform__create_request/)).toBeTruthy();
expect(screen.getByText(/Read/)).toBeTruthy();
});
it("singularizes the header for one tool", () => {
render(<ToolTraceChips trace={[{ tool: "Bash" }]} />);
expect(screen.getByText("1 tool used")).toBeTruthy();
});
});
@@ -15,11 +15,18 @@ async function loadMessagesFromDB(
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
// The server emits ChatMessage with a snake_case `tool_trace` field
// (Go json tag). Map it onto the camelCase `toolTrace` the renderer
// reads so a rehydrated agent turn shows its tool chain (core#2636).
const resp = await api.get<{
messages: (ChatMessage & { tool_trace?: ChatMessage["toolTrace"] })[];
reached_end: boolean;
}>(`/workspaces/${workspaceId}/chat-history?${params.toString()}`);
const messages: ChatMessage[] = (resp.messages ?? []).map((m) =>
m.tool_trace?.length ? { ...m, toolTrace: m.tool_trace } : m,
);
return {
messages: resp.messages ?? [],
messages,
error: null,
reachedEnd: resp.reached_end,
};
+13
View File
@@ -12,12 +12,23 @@ export interface ChatAttachment {
size?: number;
}
/** One tool-use step the agent ran during a turn — the persisted twin
* of the live progress lines. Server stores these in the activity
* row's tool_trace; the chat-history endpoint returns them on the
* agent message so the chain survives a reload (core#2636). */
export interface ToolTraceEntry {
tool: string;
input?: string;
}
export interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
content: string;
/** Attachments sent with or returned alongside this message. */
attachments?: ChatAttachment[];
/** Tool-use chain for an agent turn (rehydrated from tool_trace). */
toolTrace?: ToolTraceEntry[];
timestamp: string; // ISO string for serialization
}
@@ -25,6 +36,7 @@ export function createMessage(
role: ChatMessage["role"],
content: string,
attachments?: ChatAttachment[],
toolTrace?: ToolTraceEntry[],
): ChatMessage {
return Object.freeze({
id: crypto.randomUUID(),
@@ -33,6 +45,7 @@ export function createMessage(
// Conditional spread avoids `attachments: undefined` appearing in
// Object.keys() when no attachments are provided.
...(attachments?.length ? { attachments } : {}),
...(toolTrace?.length ? { toolTrace } : {}),
timestamp: new Date().toISOString(),
});
}
@@ -484,7 +484,8 @@ func (s *RequestStore) Respond(ctx context.Context, id, action, responderType, r
}
s.notifyRequesterAgent(ctx, req,
"request-responded:"+req.ID,
fmt.Sprintf("Your %s request %q (id %s) was %s by %s. Use get_request for the thread or check_requests for all your outcomes.",
fmt.Sprintf("Your %s request %q (id %s) was %s by %s. Use get_request for the thread or check_requests for all your outcomes. "+
"If this outcome changes what you owe the user, acknowledge it briefly with send_message_to_user — this notification is a background turn and does NOT appear in their chat.",
req.Kind, req.Title, req.ID, status, by))
}
@@ -61,6 +61,7 @@ package messagestore
import (
"context"
"encoding/json"
"time"
)
@@ -77,6 +78,13 @@ type ChatMessage struct {
Content string `json:"content"`
Attachments []ChatAttachment `json:"attachments,omitempty"`
Timestamp string `json:"timestamp"` // RFC3339, pinned to row.created_at
// ToolTrace is the agent turn's tool-use chain (the same
// metadata.tool_trace array the live progress feed renders), carried
// on the AGENT message so the chain survives a chat reload — without
// it the canvas dropped every tool step the moment the spinner
// cleared (core#2636). Raw passthrough of the stored JSON array of
// {tool, input} objects; omitted when the row has none.
ToolTrace json.RawMessage `json:"tool_trace,omitempty"`
}
// ChatAttachment mirrors canvas ChatAttachment / ParsedFilePart.
@@ -90,8 +90,9 @@ func (s *PostgresMessageStore) List(ctx context.Context, workspaceID string, opt
status string
rawRequest sql.NullString
rawResponse sql.NullString
rawTrace sql.NullString
)
if err := rows.Scan(&createdAt, &status, &rawRequest, &rawResponse); err != nil {
if err := rows.Scan(&createdAt, &status, &rawRequest, &rawResponse, &rawTrace); err != nil {
// Skip malformed row, continue. The error is logged at
// the caller (handler) layer; an isolated bad row should
// not abort the whole page.
@@ -105,7 +106,11 @@ func (s *PostgresMessageStore) List(ctx context.Context, workspaceID string, opt
if rawResponse.Valid {
responseBody = json.RawMessage(rawResponse.String)
}
messages = append(messages, activityRowToChatMessages(createdAt, status, requestBody, responseBody, IsInternalSelfMessage)...)
var toolTrace json.RawMessage
if rawTrace.Valid && rawTrace.String != "" && rawTrace.String != "null" {
toolTrace = json.RawMessage(rawTrace.String)
}
messages = append(messages, activityRowToChatMessages(createdAt, status, requestBody, responseBody, toolTrace, IsInternalSelfMessage)...)
}
if err := rows.Err(); err != nil {
return nil, false, err
@@ -166,7 +171,7 @@ func reverseRowChunks(msgs []ChatMessage) []ChatMessage {
func (s *PostgresMessageStore) queryActivityRows(ctx context.Context, workspaceID string, opts ListOptions) (*sql.Rows, error) {
if opts.HasBefore {
return s.db.QueryContext(ctx, `
SELECT created_at, status, request_body::text, response_body::text
SELECT created_at, status, request_body::text, response_body::text, tool_trace::text
FROM activity_logs
WHERE workspace_id = $1
AND activity_type = 'a2a_receive'
@@ -177,7 +182,7 @@ func (s *PostgresMessageStore) queryActivityRows(ctx context.Context, workspaceI
`, workspaceID, opts.BeforeTS, opts.Limit)
}
return s.db.QueryContext(ctx, `
SELECT created_at, status, request_body::text, response_body::text
SELECT created_at, status, request_body::text, response_body::text, tool_trace::text
FROM activity_logs
WHERE workspace_id = $1
AND activity_type = 'a2a_receive'
@@ -209,6 +214,7 @@ func activityRowToChatMessages(
status string,
requestBody json.RawMessage,
responseBody json.RawMessage,
toolTrace json.RawMessage,
internalSelf func(string) bool,
) []ChatMessage {
var out []ChatMessage
@@ -240,6 +246,7 @@ func activityRowToChatMessages(
Content: agentText,
Attachments: agentAttachments,
Timestamp: timestamp,
ToolTrace: toolTrace,
})
}
}
@@ -48,7 +48,7 @@ func TestChatHistory_UserMessageTimestampPinsToCreatedAt(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:00:00Z")
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hello from earlier today"}]}}}`)
msgs := activityRowToChatMessages(created, "ok", body, nil, neverInternal)
msgs := activityRowToChatMessages(created, "ok", body, nil, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 user message, got %d", len(msgs))
}
@@ -64,7 +64,7 @@ func TestChatHistory_AgentMessageTimestampPinsToCreatedAt(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:05:00Z")
body := json.RawMessage(`{"result":"agent reply"}`)
msgs := activityRowToChatMessages(created, "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(created, "ok", nil, body, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 agent message, got %d", len(msgs))
}
@@ -79,8 +79,8 @@ func TestChatHistory_AgentMessageTimestampPinsToCreatedAt(t *testing.T) {
func TestChatHistory_TwoRowsDistinctTimestamps(t *testing.T) {
bodyA := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"first"}]}}}`)
bodyB := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"second"}]}}}`)
a := activityRowToChatMessages(mustParseTime(t, "2026-04-25T14:00:00Z"), "ok", bodyA, nil, neverInternal)
b := activityRowToChatMessages(mustParseTime(t, "2026-04-25T21:01:58Z"), "ok", bodyB, nil, neverInternal)
a := activityRowToChatMessages(mustParseTime(t, "2026-04-25T14:00:00Z"), "ok", bodyA, nil, nil, neverInternal)
b := activityRowToChatMessages(mustParseTime(t, "2026-04-25T21:01:58Z"), "ok", bodyB, nil, nil, neverInternal)
if len(a) != 1 || len(b) != 1 {
t.Fatalf("expected 1 message each; got %d and %d", len(a), len(b))
@@ -99,7 +99,7 @@ func TestChatHistory_TwoRowsDistinctTimestamps(t *testing.T) {
func TestChatHistory_EmitsUserMessageWhenRequestHasText(t *testing.T) {
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hi agent"}]}}}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
@@ -111,7 +111,7 @@ func TestChatHistory_EmitsUserMessageWhenRequestHasText(t *testing.T) {
func TestChatHistory_DropsInternalSelfMessages(t *testing.T) {
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"Delegation results are ready..."}]}}}`)
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, predicate)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("internal-self message rendered as user bubble: %q", m.Content)
@@ -120,7 +120,7 @@ func TestChatHistory_DropsInternalSelfMessages(t *testing.T) {
}
func TestChatHistory_NoUserMessageWhenRequestBodyNull(t *testing.T) {
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, nil, neverInternal)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("emitted user bubble despite null request_body: %+v", m)
@@ -139,7 +139,7 @@ func TestChatHistory_UserAttachmentsHydratedFromRequestBody(t *testing.T) {
}
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, neverInternal)
var user *ChatMessage
for i := range msgs {
if msgs[i].Role == "user" {
@@ -176,7 +176,7 @@ func TestChatHistory_AttachmentsOnlyUserBubbleWhenTextEmpty(t *testing.T) {
}
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 attachments-only bubble, got %d", len(msgs))
}
@@ -200,7 +200,7 @@ func TestChatHistory_InternalSelfPredicateSuppressesEvenWithAttachments(t *testi
}
}`)
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, predicate)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("internal-self predicate did NOT suppress user bubble despite attachments: %+v", m)
@@ -214,7 +214,7 @@ func TestChatHistory_InternalSelfPredicateSuppressesEvenWithAttachments(t *testi
func TestChatHistory_AgentMessageFromResultString(t *testing.T) {
body := json.RawMessage(`{"result":"agent says hi"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "agent" || msgs[0].Content != "agent says hi" {
t.Errorf("got %+v", msgs)
}
@@ -222,7 +222,7 @@ func TestChatHistory_AgentMessageFromResultString(t *testing.T) {
func TestChatHistory_RoleSystemWhenStatusError(t *testing.T) {
body := json.RawMessage(`{"result":"delegation failed"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "error", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "error", nil, body, nil, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "system" {
t.Errorf("status=error did NOT promote role to system: %+v", msgs)
}
@@ -233,7 +233,7 @@ func TestChatHistory_RoleSystemWhenAgentErrorPrefix(t *testing.T) {
// itself starts with "agent error", the canvas would still
// render system role. Mirror that here.
body := json.RawMessage(`{"result":"Agent error: ProcessError(exit=1)"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "system" {
t.Errorf("agent-error prefix did NOT promote to system: %+v", msgs)
}
@@ -247,7 +247,7 @@ func TestChatHistory_AgentAttachmentsFromResponseBodyParts(t *testing.T) {
{"kind":"file","file":{"name":"build.zip","uri":"workspace:/tmp/build.zip","size":12345}}
]
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
var agent *ChatMessage
for i := range msgs {
if msgs[i].Role == "agent" {
@@ -267,7 +267,7 @@ func TestChatHistory_AgentAttachmentsFromResponseBodyParts(t *testing.T) {
}
func TestChatHistory_NoAgentMessageWhenResponseBodyNull(t *testing.T) {
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, nil, neverInternal)
for _, m := range msgs {
if m.Role == "agent" || m.Role == "system" {
t.Errorf("emitted agent/system bubble despite null response_body: %+v", m)
@@ -277,7 +277,7 @@ func TestChatHistory_NoAgentMessageWhenResponseBodyNull(t *testing.T) {
func TestChatHistory_NoAgentMessageWhenResponseHasNoTextNoFiles(t *testing.T) {
body := json.RawMessage(`{"unrelated":"metadata"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
for _, m := range msgs {
if m.Role == "agent" {
t.Errorf("emitted agent bubble despite empty content: %+v", m)
@@ -309,16 +309,16 @@ func TestList_WireOrderIsOldestFirstAcrossPagedRows(t *testing.T) {
// Server's SQL is ORDER BY created_at DESC. Build mock rows in
// THAT order so the row-aware reversal has work to do.
rows := sqlmock.NewRows([]string{"created_at", "status", "request_body", "response_body"}).
rows := sqlmock.NewRows([]string{"created_at", "status", "request_body", "response_body", "tool_trace"}).
AddRow(mustParseTime(t, "2026-05-05T00:03:00Z"), "ok",
`{"params":{"message":{"parts":[{"kind":"text","text":"u3"}]}}}`,
`{"result":"a3"}`).
`{"result":"a3"}`, nil).
AddRow(mustParseTime(t, "2026-05-05T00:02:00Z"), "ok",
`{"params":{"message":{"parts":[{"kind":"text","text":"u2"}]}}}`,
`{"result":"a2"}`).
`{"result":"a2"}`, nil).
AddRow(mustParseTime(t, "2026-05-05T00:01:00Z"), "ok",
`{"params":{"message":{"parts":[{"kind":"text","text":"u1"}]}}}`,
`{"result":"a1"}`)
`{"result":"a1"}`, nil)
mock.ExpectQuery(`SELECT created_at, status, request_body::text, response_body::text`).
WillReturnRows(rows)
@@ -432,7 +432,7 @@ func TestChatHistory_PairedUserAndAgentSameTimestamp(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:00:00Z")
req := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"what's 2+2?"}]}}}`)
resp := json.RawMessage(`{"result":"4"}`)
msgs := activityRowToChatMessages(created, "ok", req, resp, neverInternal)
msgs := activityRowToChatMessages(created, "ok", req, resp, nil, neverInternal)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
@@ -459,7 +459,7 @@ func TestChatHistory_MalformedJSONInRequestBodyReturnsEmpty(t *testing.T) {
t.Fatalf("panic on malformed json: %v", r)
}
}()
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, nil, neverInternal)
for _, m := range msgs {
if m.Role == "user" && (m.Content != "" || len(m.Attachments) > 0) {
t.Errorf("malformed JSON yielded a non-empty user bubble: %+v", m)
@@ -476,7 +476,7 @@ func TestChatHistory_V1ProtobufFlatFileShape(t *testing.T) {
]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
var agent *ChatMessage
for i := range msgs {
if msgs[i].Role == "agent" {
@@ -505,7 +505,7 @@ func TestChatHistory_TaskShapeArtifactsExtracted(t *testing.T) {
]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
if len(msgs) != 1 || msgs[0].Content != "hermes detail line" {
t.Errorf("artifact text not extracted: %+v", msgs)
}
@@ -518,7 +518,7 @@ func TestChatHistory_OlderNestedRootTextShape(t *testing.T) {
"parts": [{"root":{"text":"legacy nested text"}}]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, nil, neverInternal)
if len(msgs) != 1 || !strings.Contains(msgs[0].Content, "legacy nested text") {
t.Errorf("nested root.text not extracted: %+v", msgs)
}
@@ -562,3 +562,24 @@ func TestChatHistory_BasenameStripsSchemeAndPath(t *testing.T) {
}
}
}
// TestActivityRow_AgentMessageCarriesToolTrace (core#2636): the tool-use
// chain must ride on the agent message so a chat reload re-renders it.
func TestActivityRow_AgentMessageCarriesToolTrace(t *testing.T) {
trace := json.RawMessage(`[{"tool":"mcp__platform__create_request","input":"{}"}]`)
msgs := activityRowToChatMessages(
mustParseTime(t, "2026-06-12T00:00:00Z"), "ok",
json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"do it"}]}}}`),
json.RawMessage(`{"result":"done"}`),
trace, neverInternal,
)
if len(msgs) != 2 {
t.Fatalf("want 2 messages (user+agent), got %d", len(msgs))
}
if msgs[0].Role != "user" || msgs[0].ToolTrace != nil {
t.Errorf("user message must not carry tool_trace: %+v", msgs[0])
}
if msgs[1].Role != "agent" || string(msgs[1].ToolTrace) != string(trace) {
t.Errorf("agent message must carry the tool_trace; got %q", string(msgs[1].ToolTrace))
}
}