feat(chat): persist agent tool-chain across reload + nudge requester ack into My Chat (core#2636) #2637
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user