mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): stop inlining subagent transcripts in the session timeline
A session with 44 linked subagent transcripts returned a 541,817,705-byte / 539,323,608-character /messages body — past V8's 536,870,888-character string limit, where Chromium hands the renderer an empty string. The session opened to "Unexpected end of JSON input" instead of its history. HTTP now serves the root transcript only; an Agent card fetches its run's tool stream from /subagents/by-tool when expanded, reusing the truncation the server already applies to oversized runs. Rewind checkpoints, team task anchors and workspace change attribution keep the merged view they depend on. Measured on the session that failed: /messages 542 MB -> 40 MB; one run payload 45.8 MB -> 22.9 MB (the Activity projection is no longer sent twice when it is the same array).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
api,
|
||||
ApiResponseParseError,
|
||||
getApiUrl,
|
||||
getDefaultBaseUrl,
|
||||
rawRecordDiagnosticEvent,
|
||||
@@ -288,7 +289,8 @@ describe('api diagnostics reporting', () => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => new Promise((_resolve, reject) => {
|
||||
headers: new Headers(),
|
||||
text: () => new Promise((_resolve, reject) => {
|
||||
bodyReadStarted = true
|
||||
requestSignal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
||||
@@ -386,4 +388,52 @@ describe('api diagnostics reporting', () => {
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flags an oversized 200 body as too large without downloading it', async () => {
|
||||
let bodyRead = false
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockImplementation(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'content-length': '541817705',
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
text: () => {
|
||||
bodyRead = true
|
||||
return Promise.resolve('')
|
||||
},
|
||||
} as unknown as Response))
|
||||
|
||||
const error = await api.get('/api/sessions/huge/messages').catch((err: unknown) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiResponseParseError)
|
||||
expect((error as ApiResponseParseError).tooLarge).toBe(true)
|
||||
expect((error as ApiResponseParseError).bytes).toBe(541_817_705)
|
||||
// Reading it would only burn memory: this runtime cannot make a string
|
||||
// that long, so the answer is already known.
|
||||
expect(bodyRead).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a truncated 200 body with byte counts instead of a bare SyntaxError', async () => {
|
||||
const truncated = '{"messages":['
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValue(new Response(truncated, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
|
||||
const error = await api.get('/api/sessions/truncated/messages').catch((err: unknown) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiResponseParseError)
|
||||
expect((error as ApiResponseParseError).tooLarge).toBe(false)
|
||||
expect((error as ApiResponseParseError).readChars).toBe(truncated.length)
|
||||
|
||||
const diagnosticCall = fetchMock.mock.calls.find(
|
||||
([url]) => String(url).endsWith('/api/diagnostics/events'),
|
||||
)
|
||||
const details = JSON.parse(String((diagnosticCall?.[1] as RequestInit | undefined)?.body)).details
|
||||
expect(details.readChars).toBe(truncated.length)
|
||||
expect(details.contentType).toBe('application/json')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,6 +73,52 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium turns a response body larger than V8's maximum string length
|
||||
* (2^29 - 24 = 536,870,888 characters) into an *empty* string rather than
|
||||
* throwing, so the failure surfaces as a bare `Unexpected end of JSON input`
|
||||
* with no status — indistinguishable from real corruption. This carries the
|
||||
* numbers that tell the two apart.
|
||||
*
|
||||
* Character count can never exceed byte count for UTF-8, so a byte ceiling is
|
||||
* a safe proxy for the string limit. It sits just under the real cap (530 MB
|
||||
* vs 536,870,888 chars) so a body that could still be parsed is not refused.
|
||||
*/
|
||||
const MAX_JSON_RESPONSE_BYTES = 530_000_000
|
||||
|
||||
export class ApiResponseParseError extends Error {
|
||||
readonly bytes: number
|
||||
readonly readChars: number
|
||||
readonly contentType: string | null
|
||||
|
||||
constructor(details: {
|
||||
bytes: number
|
||||
readChars: number
|
||||
contentType: string | null
|
||||
}) {
|
||||
super('The server response could not be parsed as JSON.')
|
||||
this.name = 'ApiResponseParseError'
|
||||
this.bytes = details.bytes
|
||||
this.readChars = details.readChars
|
||||
this.contentType = details.contentType
|
||||
}
|
||||
|
||||
/** The response was bigger than any string this runtime can hold. */
|
||||
get tooLarge(): boolean {
|
||||
return this.bytes >= MAX_JSON_RESPONSE_BYTES
|
||||
}
|
||||
|
||||
/**
|
||||
* A 200 whose body read back as nothing. When no size was declared this is
|
||||
* what an over-limit body looks like from here (Blink returns an empty
|
||||
* string), but a truncated transfer can produce the same shape — hence a
|
||||
* separate flag rather than folding it into `tooLarge`.
|
||||
*/
|
||||
get emptyBody(): boolean {
|
||||
return this.readChars === 0
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
@@ -119,7 +165,7 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return await res.json() as T
|
||||
return await readJsonBody<T>(res)
|
||||
} catch (err) {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
|
||||
@@ -139,6 +185,38 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody<T>(res: Response): Promise<T> {
|
||||
const contentType = res.headers.get('content-type')
|
||||
const declaredLength = Number.parseInt(res.headers.get('content-length') ?? '', 10)
|
||||
const declaredBytes = Number.isFinite(declaredLength) && declaredLength > 0
|
||||
? declaredLength
|
||||
: 0
|
||||
|
||||
// Refuse an oversized body before it is downloaded: this runtime cannot turn
|
||||
// one into a string, so reading it would only burn memory to produce the same
|
||||
// answer.
|
||||
if (declaredBytes >= MAX_JSON_RESPONSE_BYTES) {
|
||||
throw new ApiResponseParseError({
|
||||
bytes: declaredBytes,
|
||||
readChars: 0,
|
||||
contentType,
|
||||
})
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
// A truncated or empty body has no status to report: the request itself
|
||||
// succeeded, so the byte counts are the only usable evidence.
|
||||
throw new ApiResponseParseError({
|
||||
bytes: declaredBytes || text.length,
|
||||
readChars: text.length,
|
||||
contentType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverDesktopServerUrl(): Promise<boolean> {
|
||||
const host = getDesktopHost()
|
||||
if (!host.isDesktop) return false
|
||||
@@ -173,6 +251,13 @@ function reportApiFailure(method: string, path: string, error: unknown) {
|
||||
details.response = sanitizeDiagnosticValue(error.body)
|
||||
}
|
||||
|
||||
if (error instanceof ApiResponseParseError) {
|
||||
details.bytes = error.bytes
|
||||
details.readChars = error.readChars
|
||||
details.contentType = error.contentType
|
||||
details.emptyBody = error.emptyBody
|
||||
}
|
||||
|
||||
void rawRecordDiagnosticEvent({
|
||||
type: 'client_api_request_failed',
|
||||
severity: 'warn',
|
||||
|
||||
@@ -24,7 +24,11 @@ export type SubagentRunResponse = {
|
||||
outputFile?: string
|
||||
usage?: SubagentRunUsage
|
||||
messages: MessageEntry[]
|
||||
/** Full Activity projection; conversation messages may be truncated. */
|
||||
/**
|
||||
* Full Activity projection, sent only when `truncated` is true — otherwise
|
||||
* `messages` already is that projection, and the server does not ship the
|
||||
* same array twice. Callers fall back to `messages`.
|
||||
*/
|
||||
activityMessages?: MessageEntry[]
|
||||
/** Structured terminal events hidden from the visible transcript. */
|
||||
taskNotifications?: AgentTaskNotification[]
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
} from './virtualHeightCache'
|
||||
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { subagentsApi, type SubagentRunResponse } from '../../api/subagents'
|
||||
import { teamsApi } from '../../api/teams'
|
||||
import { resetAgentRunActivityCache } from './useAgentRunActivity'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspaceStore } from '../../stores/workspaceStore'
|
||||
@@ -32,6 +34,7 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
import type { MessageEntry } from '../../types/session'
|
||||
import type { PerSessionState } from '../../stores/chatStore'
|
||||
import { FindInPageModal } from '../search/FindInPageModal'
|
||||
|
||||
@@ -8866,3 +8869,201 @@ describe('Agent Teams chat projection', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageList agent card activity', () => {
|
||||
const AGENT_TOOL_USE_ID = 'agent-1'
|
||||
|
||||
const runWithActivity = (activityMessages: MessageEntry[]): SubagentRunResponse => ({
|
||||
sessionId: ACTIVE_TAB,
|
||||
toolUseId: AGENT_TOOL_USE_ID,
|
||||
agentId: 'agent-abc',
|
||||
status: 'completed',
|
||||
messages: [],
|
||||
activityMessages,
|
||||
truncated: false,
|
||||
source: 'subagent-jsonl',
|
||||
})
|
||||
|
||||
const CHILD_ACTIVITY: MessageEntry[] = [
|
||||
{
|
||||
id: 'child-tool',
|
||||
type: 'tool_use',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'Read:0', name: 'Read', input: { file_path: '/tmp/alpha.txt' } },
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
id: 'child-result',
|
||||
type: 'tool_result',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'Read:0', content: 'alpha body' },
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
resetAgentRunActivityCache()
|
||||
resetSessionScrollSnapshotsForTests()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useTabStore.setState({
|
||||
activeTabId: ACTIVE_TAB,
|
||||
tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }],
|
||||
})
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useTeamStore.getState().clearTeam()
|
||||
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
|
||||
useWorkspaceStore.setState(useWorkspaceStore.getInitialState(), true)
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
)
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetAgentRunActivityCache()
|
||||
})
|
||||
|
||||
function renderDispatchedAgent() {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: AGENT_TOOL_USE_ID,
|
||||
input: { description: 'Inspect alpha' },
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
return render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
}
|
||||
|
||||
/** The card lives one disclosure level below the agent group header. */
|
||||
function openAgentCard() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Expand agent/ }))
|
||||
}
|
||||
|
||||
it('fetches a run’s tool stream only once its Agent card is expanded', async () => {
|
||||
const getRunByTool = vi.spyOn(subagentsApi, 'getRunByTool')
|
||||
.mockResolvedValue(runWithActivity(CHILD_ACTIVITY))
|
||||
|
||||
renderDispatchedAgent()
|
||||
|
||||
// Expanding the group mounts the card — that alone must not fetch.
|
||||
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/ }))
|
||||
expect(screen.getByRole('button', { name: /Expand agent/ })).toBeTruthy()
|
||||
expect(getRunByTool).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Expand agent/ }))
|
||||
await waitFor(() => {
|
||||
expect(getRunByTool).toHaveBeenCalledWith(ACTIVE_TAB, AGENT_TOOL_USE_ID, undefined)
|
||||
})
|
||||
expect(await screen.findByTestId('agent-call-activity')).toBeTruthy()
|
||||
expect(screen.getByText('alpha.txt')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the live child stream instead of fetching when the card already has one', async () => {
|
||||
const getRunByTool = vi.spyOn(subagentsApi, 'getRunByTool')
|
||||
.mockResolvedValue(runWithActivity(CHILD_ACTIVITY))
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: AGENT_TOOL_USE_ID,
|
||||
input: { description: 'Inspect alpha' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'child-tool',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'Read:0',
|
||||
parentToolUseId: AGENT_TOOL_USE_ID,
|
||||
input: { file_path: '/tmp/live-child.txt' },
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||||
openAgentCard()
|
||||
|
||||
// Live runs stream their children into the timeline; that stays the source
|
||||
// of truth, and the run's own endpoint is not asked again.
|
||||
expect(screen.getByText('live-child.txt')).toBeTruthy()
|
||||
expect(getRunByTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the run’s tool stream out of the request once it has been fetched', async () => {
|
||||
const getRunByTool = vi.spyOn(subagentsApi, 'getRunByTool')
|
||||
.mockResolvedValue(runWithActivity(CHILD_ACTIVITY))
|
||||
|
||||
renderDispatchedAgent()
|
||||
openAgentCard()
|
||||
await screen.findByTestId('agent-call-activity')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Collapse agent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand agent' }))
|
||||
await screen.findByTestId('agent-call-activity')
|
||||
|
||||
expect(getRunByTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers a retry when the run’s tool stream cannot be loaded', async () => {
|
||||
const getRunByTool = vi.spyOn(subagentsApi, 'getRunByTool')
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(runWithActivity(CHILD_ACTIVITY))
|
||||
|
||||
renderDispatchedAgent()
|
||||
openAgentCard()
|
||||
|
||||
const failed = await screen.findByTestId('agent-call-activity-error')
|
||||
fireEvent.click(within(failed).getByRole('button', { name: /Retry/ }))
|
||||
|
||||
expect(await screen.findByTestId('agent-call-activity')).toBeTruthy()
|
||||
expect(getRunByTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('trims an oversized run and says so', async () => {
|
||||
const manyMessages: MessageEntry[] = Array.from({ length: 1001 }, (_, index) => ({
|
||||
id: `child-${index}`,
|
||||
type: 'tool_use' as const,
|
||||
content: [
|
||||
{ type: 'tool_use', id: `Read:${index}`, name: 'Read', input: { file_path: `/tmp/f${index}.txt` } },
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
}))
|
||||
vi.spyOn(subagentsApi, 'getRunByTool').mockResolvedValue(runWithActivity(manyMessages))
|
||||
|
||||
renderDispatchedAgent()
|
||||
openAgentCard()
|
||||
|
||||
expect(await screen.findByTestId('agent-call-activity')).toBeTruthy()
|
||||
expect(screen.getByText(/Showing the start and end of this run/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,11 @@ import { UserMessage } from './UserMessage'
|
||||
import { AssistantMessage } from './AssistantMessage'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { ToolCallGroup, type OpenAgentRunPayload } from './ToolCallGroup'
|
||||
import {
|
||||
ToolCallGroup,
|
||||
type OpenAgentRunPayload,
|
||||
type ResolveAgentActivityTarget,
|
||||
} from './ToolCallGroup'
|
||||
import type { ActivityStep } from './activityGroupModel'
|
||||
import { ToolResultBlock } from './ToolResultBlock'
|
||||
import { PermissionDialog } from './PermissionDialog'
|
||||
@@ -1527,6 +1531,12 @@ type MessageListProps = {
|
||||
compact?: boolean
|
||||
mobileLayout?: boolean
|
||||
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
||||
/**
|
||||
* Lets a host that renders the list under a non-session id (an agent run's
|
||||
* own tab) tell an Agent card which session and tool ref its detail endpoint
|
||||
* lives behind. Defaults to the card's own ids.
|
||||
*/
|
||||
resolveAgentActivityTarget?: ResolveAgentActivityTarget
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
||||
@@ -2229,6 +2239,7 @@ export function MessageList({
|
||||
compact = false,
|
||||
mobileLayout = false,
|
||||
onOpenAgentRun,
|
||||
resolveAgentActivityTarget,
|
||||
}: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
@@ -3475,6 +3486,7 @@ export function MessageList({
|
||||
<ToolCallGroup
|
||||
sessionId={resolvedSessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
toolCalls={item.toolCalls}
|
||||
steps={item.steps}
|
||||
resultMap={toolResultMap}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './activityGroupModel'
|
||||
import { ImageGenerationGroup, type ImageGenerationItem } from './ImageGenerationBlock'
|
||||
import { isImageGenerationToolName } from './imageGenerationTools'
|
||||
import { useAgentRunActivity } from './useAgentRunActivity'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { Badge, type Tone } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -67,9 +68,26 @@ function useExpandableCardState() {
|
||||
return { expanded, toggleExpanded }
|
||||
}
|
||||
|
||||
export type AgentActivityTarget = {
|
||||
sessionId: string
|
||||
toolUseId: string
|
||||
taskId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a card's own ids onto the run that `/subagents/by-tool` can resolve.
|
||||
*
|
||||
* The session timeline addresses an Agent card with the session it is rendered
|
||||
* in; an agent run's own page renders the same cards but its tab id is
|
||||
* `subagent:*`, and a nested child has to be addressed through its parent's
|
||||
* tool ref. Without this hook the detail fetch would 404 for both.
|
||||
*/
|
||||
export type ResolveAgentActivityTarget = (input: AgentActivityTarget) => AgentActivityTarget | null
|
||||
|
||||
type Props = {
|
||||
sessionId?: string | null
|
||||
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
||||
resolveAgentActivityTarget?: ResolveAgentActivityTarget
|
||||
toolCalls: ToolCall[]
|
||||
/**
|
||||
* The run in transcript order, including any thinking blocks that happened
|
||||
@@ -99,6 +117,7 @@ export type OpenAgentRunPayload = {
|
||||
export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
resolveAgentActivityTarget,
|
||||
toolCalls,
|
||||
steps,
|
||||
resultMap,
|
||||
@@ -132,6 +151,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
<ToolCallGroupContent
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
steps={regularSteps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -150,6 +170,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
<ToolCallGroupContent
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
steps={resolvedSteps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -168,6 +189,7 @@ type ContentProps = Omit<Props, 'toolCalls' | 'steps'> & { steps: ActivityStep[]
|
||||
function ToolCallGroupContent({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
resolveAgentActivityTarget,
|
||||
steps,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
@@ -225,6 +247,7 @@ function ToolCallGroupContent({
|
||||
key={`regular-${index}`}
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
steps={segment.steps}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -260,6 +283,7 @@ function ToolCallGroupContent({
|
||||
<AgentToolGroup
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
toolCalls={toolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -413,6 +437,7 @@ function MemoryToolActivityGroup({
|
||||
function AgentToolGroup({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
resolveAgentActivityTarget,
|
||||
toolCalls,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
@@ -478,6 +503,7 @@ function AgentToolGroup({
|
||||
key={toolCall.id}
|
||||
sessionId={sessionId}
|
||||
onOpenAgentRun={onOpenAgentRun}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
toolCall={toolCall}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
@@ -496,6 +522,7 @@ function AgentToolGroup({
|
||||
function AgentCallCard({
|
||||
sessionId,
|
||||
onOpenAgentRun,
|
||||
resolveAgentActivityTarget,
|
||||
toolCall,
|
||||
resultMap,
|
||||
childToolCallsByParent,
|
||||
@@ -505,6 +532,7 @@ function AgentCallCard({
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
||||
resolveAgentActivityTarget?: ResolveAgentActivityTarget
|
||||
toolCall: ToolCall
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
@@ -522,11 +550,31 @@ function AgentCallCard({
|
||||
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
||||
const isLaunchResult = isAgentLaunchResult(result?.content)
|
||||
const recentToolCalls = childToolCalls.slice(-2)
|
||||
// The timeline no longer carries child tool messages, so a card that has none
|
||||
// reads the run from its own endpoint the moment it is expanded. Live cards
|
||||
// still stream children in, and those stay the source of truth.
|
||||
const activityTarget = useMemo(() => {
|
||||
if (!sessionId) return null
|
||||
const target = { sessionId, toolUseId: toolCall.toolUseId, taskId: agentTaskNotification?.taskId }
|
||||
return resolveAgentActivityTarget ? resolveAgentActivityTarget(target) : target
|
||||
}, [agentTaskNotification?.taskId, resolveAgentActivityTarget, sessionId, toolCall.toolUseId])
|
||||
const { state: activityState, retry: retryActivity } = useAgentRunActivity({
|
||||
enabled: expanded && childToolCalls.length === 0 && activityTarget !== null,
|
||||
sessionId: activityTarget?.sessionId,
|
||||
toolUseId: activityTarget?.toolUseId,
|
||||
taskId: activityTarget?.taskId,
|
||||
})
|
||||
const lazyActivity = activityState.status === 'ready' ? activityState.activity : null
|
||||
// The hook starts in `idle` and only flips to `loading` after paint; without
|
||||
// this the expanded panel would flash "No tool activity yet" for one frame.
|
||||
const showActivityLoading =
|
||||
expanded && childToolCalls.length === 0 && activityTarget !== null &&
|
||||
lazyActivity === null && activityState.status !== 'error'
|
||||
const status = getAgentStatus({
|
||||
hasResult: !!result,
|
||||
isError: !!result?.isError,
|
||||
isLaunchResult,
|
||||
childCount: childToolCalls.length,
|
||||
childCount: childToolCalls.length || (lazyActivity?.toolCalls.length ?? 0),
|
||||
taskStatus: agentTaskNotification?.status ?? agentTaskStatus,
|
||||
})
|
||||
const statusTone = getAgentStatusTone(status)
|
||||
@@ -660,6 +708,41 @@ function AgentCallCard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : showActivityLoading ? (
|
||||
<div
|
||||
data-testid="agent-call-activity-loading"
|
||||
className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{t('agentActivity.loading')}
|
||||
</div>
|
||||
) : activityState.status === 'error' ? (
|
||||
<div
|
||||
data-testid="agent-call-activity-error"
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
<span>{t('agentActivity.failed')}</span>
|
||||
<Button variant="ghost" size="sm" onClick={retryActivity}>
|
||||
{t('agentActivity.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : lazyActivity && lazyActivity.toolCalls.length > 0 ? (
|
||||
<div className="space-y-0.5" data-testid="agent-call-activity">
|
||||
{lazyActivity.toolCalls.map((childToolCall) => (
|
||||
<ToolCallTree
|
||||
key={childToolCall.id}
|
||||
toolCall={childToolCall}
|
||||
resultMap={lazyActivity.resultMap}
|
||||
childToolCallsByParent={lazyActivity.childToolCallsByParent}
|
||||
compact
|
||||
chrome="row"
|
||||
/>
|
||||
))}
|
||||
{lazyActivity.truncated && (
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentActivity.truncated')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : outputSummary ? (
|
||||
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentStatus.noActivity')}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { subagentsApi } from '../../api/subagents'
|
||||
import { mapHistoryMessagesToUiMessages } from '../../stores/chatStore'
|
||||
import type { MessageEntry } from '../../types/session'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
|
||||
export type AgentRunActivity = {
|
||||
toolCalls: ToolCall[]
|
||||
resultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
/** The inline stream was cut down to head + tail; the full run lives on its own page. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type AgentRunActivityState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'ready'; activity: AgentRunActivity }
|
||||
|
||||
/**
|
||||
* How many runs stay expanded-in-memory after they are fetched. A finished run
|
||||
* is immutable, so the cache only exists to make re-expanding a card free; it
|
||||
* is capped because a single run can carry megabytes of tool output.
|
||||
*/
|
||||
const MAX_CACHED_RUNS = 8
|
||||
/**
|
||||
* Runs are capped by message count too, not just by run count: one run can
|
||||
* carry thousands of tool results, and 8 such runs would otherwise sit in the
|
||||
* heap for the life of the window.
|
||||
*/
|
||||
const MAX_CACHED_MESSAGES = 8_000
|
||||
/**
|
||||
* A run's transcript file is append-only while the agent lives, and a teammate
|
||||
* can be resumed after it finished. Entries older than this are re-fetched so
|
||||
* a cached snapshot cannot outlive the run it describes.
|
||||
*/
|
||||
const ACTIVITY_CACHE_TTL_MS = 5 * 60_000
|
||||
/** Mirror of the server's `truncateSubagentMessages`: >1000 keeps first 50 + last 950. */
|
||||
const TRUNCATE_THRESHOLD = 1000
|
||||
const TRUNCATE_HEAD = 50
|
||||
const TRUNCATE_TAIL = 950
|
||||
|
||||
type CachedActivity = {
|
||||
activity: AgentRunActivity
|
||||
messageCount: number
|
||||
cachedAt: number
|
||||
}
|
||||
|
||||
// Module scope on purpose: expanding a card, collapsing it, and expanding it
|
||||
// again must not re-download the run, and two cards asking for the same run at
|
||||
// once must share one request.
|
||||
const activityCache = new Map<string, CachedActivity>()
|
||||
const activityRequests = new Map<string, Promise<AgentRunActivity>>()
|
||||
|
||||
function activityKey(sessionId: string, toolUseId: string, taskId?: string | null) {
|
||||
return `${sessionId}\u0000${toolUseId}\u0000${taskId ?? ''}`
|
||||
}
|
||||
|
||||
function cachedMessageTotal() {
|
||||
let total = 0
|
||||
for (const entry of activityCache.values()) total += entry.messageCount
|
||||
return total
|
||||
}
|
||||
|
||||
function readCachedActivity(key: string): AgentRunActivity | undefined {
|
||||
const cached = activityCache.get(key)
|
||||
if (!cached) return undefined
|
||||
if (Date.now() - cached.cachedAt > ACTIVITY_CACHE_TTL_MS) {
|
||||
activityCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
// Refresh recency: Map iteration order is insertion order, and eviction
|
||||
// always drops the oldest entry.
|
||||
activityCache.delete(key)
|
||||
activityCache.set(key, cached)
|
||||
return cached.activity
|
||||
}
|
||||
|
||||
function writeCachedActivity(key: string, activity: AgentRunActivity, messageCount: number) {
|
||||
activityCache.set(key, { activity, messageCount, cachedAt: Date.now() })
|
||||
// Keep at least the newest entry even if it alone exceeds the budget.
|
||||
while (
|
||||
activityCache.size > 1 &&
|
||||
(activityCache.size > MAX_CACHED_RUNS || cachedMessageTotal() > MAX_CACHED_MESSAGES)
|
||||
) {
|
||||
const oldest = activityCache.keys().next()
|
||||
if (oldest.done) break
|
||||
activityCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
function truncateActivityMessages(messages: MessageEntry[]): {
|
||||
messages: MessageEntry[]
|
||||
truncated: boolean
|
||||
} {
|
||||
if (messages.length <= TRUNCATE_THRESHOLD) {
|
||||
return { messages, truncated: false }
|
||||
}
|
||||
return {
|
||||
messages: [
|
||||
...messages.slice(0, TRUNCATE_HEAD),
|
||||
...messages.slice(-TRUNCATE_TAIL),
|
||||
],
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
function buildActivity(messages: MessageEntry[], truncated: boolean): AgentRunActivity {
|
||||
const uiMessages = mapHistoryMessagesToUiMessages(messages, {
|
||||
includeTeammateMessages: true,
|
||||
})
|
||||
const toolCalls: ToolCall[] = []
|
||||
const resultMap = new Map<string, ToolResult>()
|
||||
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
||||
|
||||
for (const message of uiMessages) {
|
||||
if (message.type === 'tool_result') {
|
||||
resultMap.set(message.toolUseId, message)
|
||||
continue
|
||||
}
|
||||
if (message.type !== 'tool_use') continue
|
||||
if (message.parentToolUseId) {
|
||||
const siblings = childToolCallsByParent.get(message.parentToolUseId)
|
||||
if (siblings) siblings.push(message)
|
||||
else childToolCallsByParent.set(message.parentToolUseId, [message])
|
||||
continue
|
||||
}
|
||||
toolCalls.push(message)
|
||||
}
|
||||
|
||||
return { toolCalls, resultMap, childToolCallsByParent, truncated }
|
||||
}
|
||||
|
||||
async function loadActivity(
|
||||
key: string,
|
||||
sessionId: string,
|
||||
toolUseId: string,
|
||||
taskId?: string | null,
|
||||
): Promise<AgentRunActivity> {
|
||||
const pending = activityRequests.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const request = subagentsApi
|
||||
.getRunByTool(sessionId, toolUseId, taskId ?? undefined)
|
||||
.then((run) => {
|
||||
// The server omits `activityMessages` unless it differs from `messages`
|
||||
// (i.e. unless truncation kicked in), so the fallback is the same stream.
|
||||
const source = run.activityMessages ?? run.messages
|
||||
const truncated = truncateActivityMessages(source)
|
||||
// Servers predating `activityMessages` only send the possibly-truncated
|
||||
// `messages`; their own flag is then the only honest signal left.
|
||||
const truncatedByServer = run.activityMessages === undefined && run.truncated === true
|
||||
const activity = buildActivity(
|
||||
truncated.messages,
|
||||
truncated.truncated || truncatedByServer,
|
||||
)
|
||||
// A live run keeps producing; caching it would freeze the timeline at
|
||||
// whatever the first expand saw.
|
||||
if (run.status !== 'running') {
|
||||
writeCachedActivity(key, activity, truncated.messages.length)
|
||||
}
|
||||
return activity
|
||||
})
|
||||
.finally(() => {
|
||||
if (activityRequests.get(key) === request) activityRequests.delete(key)
|
||||
})
|
||||
|
||||
activityRequests.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one subagent run's tool stream when its Agent card is expanded.
|
||||
*
|
||||
* The session timeline deliberately no longer carries child tool messages —
|
||||
* merging them into `/messages` made one real session 542 MB, past the
|
||||
* 536,870,888-character response limit where Chromium hands back an empty body.
|
||||
* Expanding a card is the point where that detail is actually wanted, so the
|
||||
* run is read straight from `/subagents/by-tool` instead.
|
||||
*/
|
||||
export function useAgentRunActivity(params: {
|
||||
enabled: boolean
|
||||
sessionId?: string | null
|
||||
toolUseId?: string | null
|
||||
taskId?: string | null
|
||||
}): { state: AgentRunActivityState; retry: () => void } {
|
||||
const { enabled, sessionId, toolUseId, taskId } = params
|
||||
const [state, setState] = useState<AgentRunActivityState>({ status: 'idle' })
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setAttempt((value) => value + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !sessionId || !toolUseId) {
|
||||
// Bump the generation so an in-flight response cannot land on a card
|
||||
// that has since collapsed or switched to another run.
|
||||
requestIdRef.current += 1
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
const key = activityKey(sessionId, toolUseId, taskId)
|
||||
const cached = readCachedActivity(key)
|
||||
if (cached) {
|
||||
requestIdRef.current += 1
|
||||
setState({ status: 'ready', activity: cached })
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
setState({ status: 'loading' })
|
||||
|
||||
void loadActivity(key, sessionId, toolUseId, taskId).then(
|
||||
(activity) => {
|
||||
if (requestIdRef.current !== requestId) return
|
||||
setState({ status: 'ready', activity })
|
||||
},
|
||||
() => {
|
||||
if (requestIdRef.current !== requestId) return
|
||||
setState({ status: 'error' })
|
||||
},
|
||||
)
|
||||
}, [attempt, enabled, sessionId, taskId, toolUseId])
|
||||
|
||||
return { state, retry }
|
||||
}
|
||||
|
||||
/** Test seam: module-level caches would otherwise leak between test cases. */
|
||||
export function resetAgentRunActivityCache() {
|
||||
activityCache.clear()
|
||||
activityRequests.clear()
|
||||
}
|
||||
@@ -2771,6 +2771,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'agentStatus.noActivity': 'No tool activity yet',
|
||||
'agentStatus.viewResult': 'View result',
|
||||
'agentStatus.resultTitle': 'Agent result',
|
||||
'agentActivity.loading': 'Loading tool activity…',
|
||||
'agentActivity.failed': 'Could not load this run’s tool activity.',
|
||||
'agentActivity.retry': 'Retry',
|
||||
'agentActivity.truncated': 'Showing the start and end of this run. Open the run for the full trace.',
|
||||
'toolGroup.searchedWeb': 'searched the web',
|
||||
'toolGroup.fetchedOne': 'fetched a page',
|
||||
'toolGroup.fetchedMany': 'fetched {count} pages',
|
||||
@@ -3257,6 +3261,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'session.apiTokensWithCache': '{count} tokens incl. cache',
|
||||
'session.apiTokenBreakdown': 'API usage: {total} tokens · input {input} · output {output} · cache read {cacheRead} · cache write {cacheWrite}',
|
||||
'session.historyLoadFailed': 'Failed to load session history.',
|
||||
'session.historyTooLarge': 'This session history is too large to load here.',
|
||||
'session.workspaceUnavailable': 'Workspace unavailable: {dir}',
|
||||
'session.worktreeRemoved': 'The temporary workspace was cleaned up. History is still available; start a new session in {dir} to continue.',
|
||||
'session.timeJustNow': 'just now',
|
||||
|
||||
@@ -2774,6 +2774,10 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'agentStatus.noActivity': 'まだツールの動作がありません',
|
||||
'agentStatus.viewResult': '結果を表示',
|
||||
'agentStatus.resultTitle': 'エージェントの結果',
|
||||
'agentActivity.loading': 'ツールの動作を読み込み中…',
|
||||
'agentActivity.failed': 'この実行のツール動作を読み込めませんでした。',
|
||||
'agentActivity.retry': '再試行',
|
||||
'agentActivity.truncated': 'この実行の先頭と末尾のみ表示しています。完全な記録は実行画面で確認できます。',
|
||||
'toolGroup.searchedWeb': 'ウェブを検索しました',
|
||||
'toolGroup.fetchedOne': 'ページを 1 件取得しました',
|
||||
'toolGroup.fetchedMany': '{count} 件のページを取得しました',
|
||||
@@ -3260,6 +3264,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'session.apiTokensWithCache': '{count} tokens(キャッシュを含む)',
|
||||
'session.apiTokenBreakdown': 'API 使用量: {total} tokens · 入力 {input} · 出力 {output} · キャッシュ読み取り {cacheRead} · キャッシュ書き込み {cacheWrite}',
|
||||
'session.historyLoadFailed': 'セッション履歴の読み込みに失敗しました。',
|
||||
'session.historyTooLarge': 'このセッション履歴は大きすぎるため読み込めません。',
|
||||
'session.workspaceUnavailable': 'ワークスペースが利用できません: {dir}',
|
||||
'session.worktreeRemoved': '一時ワークスペースはクリーンアップされました。履歴は引き続き閲覧できます。{dir} で新しいセッションを開始してください。',
|
||||
'session.timeJustNow': 'たった今',
|
||||
|
||||
@@ -2774,6 +2774,10 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'agentStatus.noActivity': '아직 도구 활동이 없습니다',
|
||||
'agentStatus.viewResult': '결과 보기',
|
||||
'agentStatus.resultTitle': '에이전트 결과',
|
||||
'agentActivity.loading': '도구 활동을 불러오는 중…',
|
||||
'agentActivity.failed': '이 실행의 도구 활동을 불러오지 못했습니다.',
|
||||
'agentActivity.retry': '다시 시도',
|
||||
'agentActivity.truncated': '이 실행의 처음과 끝만 표시합니다. 전체 기록은 실행 화면에서 확인할 수 있습니다.',
|
||||
'toolGroup.searchedWeb': '웹을 검색했습니다',
|
||||
'toolGroup.fetchedOne': '페이지 1개를 가져왔습니다',
|
||||
'toolGroup.fetchedMany': '페이지 {count}개를 가져왔습니다',
|
||||
@@ -3260,6 +3264,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'session.apiTokensWithCache': '{count} tokens(캐시 포함)',
|
||||
'session.apiTokenBreakdown': 'API 사용량: {total} tokens · 입력 {input} · 출력 {output} · 캐시 읽기 {cacheRead} · 캐시 쓰기 {cacheWrite}',
|
||||
'session.historyLoadFailed': '세션 기록을 불러오지 못했습니다.',
|
||||
'session.historyTooLarge': '이 세션 기록이 너무 커서 여기에서 불러올 수 없습니다.',
|
||||
'session.workspaceUnavailable': '작업 공간을 사용할 수 없습니다: {dir}',
|
||||
'session.worktreeRemoved': '임시 작업 공간이 정리되었습니다. 기록은 계속 볼 수 있습니다. {dir}에서 새 세션을 시작해 계속하세요.',
|
||||
'session.timeJustNow': '방금',
|
||||
|
||||
@@ -2773,6 +2773,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentStatus.noActivity': '暫時還沒有工具活動',
|
||||
'agentStatus.viewResult': '檢視結果',
|
||||
'agentStatus.resultTitle': 'Agent 結果',
|
||||
'agentActivity.loading': '正在載入工具活動…',
|
||||
'agentActivity.failed': '無法載入該子代理的工具活動。',
|
||||
'agentActivity.retry': '重試',
|
||||
'agentActivity.truncated': '僅顯示該執行的開始與結尾,開啟執行可查看完整軌跡。',
|
||||
'toolGroup.searchedWeb': '搜尋了網頁',
|
||||
'toolGroup.fetchedOne': '獲取了一個頁面',
|
||||
'toolGroup.fetchedMany': '獲取了 {count} 個頁面',
|
||||
@@ -3259,6 +3263,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'session.apiTokensWithCache': '{count} tokens(含快取)',
|
||||
'session.apiTokenBreakdown': 'API 用量:{total} tokens · 輸入 {input} · 輸出 {output} · 快取讀取 {cacheRead} · 快取寫入 {cacheWrite}',
|
||||
'session.historyLoadFailed': '歷史會話載入失敗。',
|
||||
'session.historyTooLarge': '此會話歷史過大,無法在此載入。',
|
||||
'session.workspaceUnavailable': '工作目錄不可用: {dir}',
|
||||
'session.worktreeRemoved': '臨時工作區已清理,歷史記錄仍可查看。請在原專案 {dir} 中新建會話繼續。',
|
||||
'session.timeJustNow': '剛剛',
|
||||
|
||||
@@ -2772,6 +2772,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentStatus.noActivity': '暂时还没有工具活动',
|
||||
'agentStatus.viewResult': '查看结果',
|
||||
'agentStatus.resultTitle': 'Agent 结果',
|
||||
'agentActivity.loading': '正在加载工具活动…',
|
||||
'agentActivity.failed': '无法加载该子代理的工具活动。',
|
||||
'agentActivity.retry': '重试',
|
||||
'agentActivity.truncated': '仅显示该运行的开始与结尾,打开运行可查看完整轨迹。',
|
||||
'toolGroup.searchedWeb': '搜索了网页',
|
||||
'toolGroup.fetchedOne': '获取了一个页面',
|
||||
'toolGroup.fetchedMany': '获取了 {count} 个页面',
|
||||
@@ -3258,6 +3262,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'session.apiTokensWithCache': '{count} tokens(含缓存)',
|
||||
'session.apiTokenBreakdown': 'API 用量:{total} tokens · 输入 {input} · 输出 {output} · 缓存读取 {cacheRead} · 缓存写入 {cacheWrite}',
|
||||
'session.historyLoadFailed': '历史会话加载失败。',
|
||||
'session.historyTooLarge': '该会话历史过大,无法在此加载。',
|
||||
'session.workspaceUnavailable': '工作目录不可用: {dir}',
|
||||
'session.worktreeRemoved': '临时工作区已清理,历史记录仍可查看。请在原项目 {dir} 中新建会话继续。',
|
||||
'session.timeJustNow': '刚刚',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SubagentRunStatus,
|
||||
} from '../api/subagents'
|
||||
import { MessageList } from '../components/chat/MessageList'
|
||||
import type { AgentActivityTarget } from '../components/chat/ToolCallGroup'
|
||||
import { ChatInput } from '../components/chat/ChatInput'
|
||||
import { SessionChatHeader, SessionChatSurface } from '@/components/chat/SessionChatSurface'
|
||||
import { SessionActivityButton } from '../components/activity/SessionActivityButton'
|
||||
@@ -149,11 +150,14 @@ export function SubagentRunPage({
|
||||
teamMember?.sessionId,
|
||||
].filter((value): value is string => Boolean(value)))], [data?.agentId, teamMember])
|
||||
const activityProjectionMessages = useMemo(() => {
|
||||
if (!data?.activityMessages) return undefined
|
||||
return mapHistoryMessagesToUiMessages(data.activityMessages, {
|
||||
// The server only sends `activityMessages` when it differs from `messages`
|
||||
// (truncation); below that threshold `messages` is the same projection.
|
||||
const projection = data?.activityMessages ?? data?.messages
|
||||
if (!projection) return undefined
|
||||
return mapHistoryMessagesToUiMessages(projection, {
|
||||
includeTeammateMessages: true,
|
||||
})
|
||||
}, [data?.activityMessages])
|
||||
}, [data?.activityMessages, data?.messages])
|
||||
|
||||
const handleReturn = () => {
|
||||
const store = useTabStore.getState()
|
||||
@@ -717,6 +721,16 @@ function AgentSessionView({
|
||||
useActivityPanelStore.getState().open(targetSessionId)
|
||||
}
|
||||
}, [runAgentId, runToolUseId, sessionId, sourceSessionId])
|
||||
// Cards rendered inside this page address their runs through the source
|
||||
// session, not this tab's `subagent:*` id — and a nested card needs the same
|
||||
// canonical parent prefix the "open" button uses.
|
||||
const resolveAgentActivityTarget = useCallback(({ toolUseId, taskId }: AgentActivityTarget) => ({
|
||||
sessionId: sourceSessionId,
|
||||
toolUseId: runAgentId && runToolUseId
|
||||
? (toolUseId.includes('/') ? `${runToolUseId}/${toolUseId}` : `${runToolUseId}/${runAgentId}/${toolUseId}`)
|
||||
: toolUseId,
|
||||
taskId,
|
||||
}), [runAgentId, runToolUseId, sourceSessionId])
|
||||
const handleClearFinishedBackgroundTasks = useCallback((taskKeys: string[]) => {
|
||||
dismissBackgroundTaskKeys(sessionId, taskKeys)
|
||||
}, [dismissBackgroundTaskKeys, sessionId])
|
||||
@@ -805,6 +819,7 @@ function AgentSessionView({
|
||||
sessionId={sessionId}
|
||||
mobileLayout={isMobileLayout}
|
||||
onOpenAgentRun={handleOpenSubagent}
|
||||
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -166,6 +166,8 @@ vi.mock('./cliTaskStore', () => ({
|
||||
}))
|
||||
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { ApiResponseParseError } from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
import type { ServerMessage } from '../types/chat'
|
||||
import { useSettingsStore } from './settingsStore'
|
||||
import { runsForOwner, runsForSession, useWorkflowStore } from './workflowStore'
|
||||
@@ -11091,6 +11093,28 @@ describe('chatStore history mapping', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('explains an oversized history response instead of a bare parse error', async () => {
|
||||
vi.mocked(sessionsApi.getMessages).mockRejectedValueOnce(
|
||||
new ApiResponseParseError({
|
||||
bytes: 541_817_705,
|
||||
readChars: 0,
|
||||
contentType: 'application/json',
|
||||
}),
|
||||
)
|
||||
useChatStore.setState({
|
||||
sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'idle' }) },
|
||||
})
|
||||
|
||||
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.historyStatus).toBe('error')
|
||||
// Chromium turns a body past its string limit into an empty string, so the
|
||||
// raw failure reads "Unexpected end of JSON input" — which tells nobody
|
||||
// anything. Say what happened instead.
|
||||
expect(session?.historyError).toBe(t('session.historyTooLarge'))
|
||||
})
|
||||
|
||||
it('flushes a cached stop failure when a task start makes history stale', async () => {
|
||||
let resolveHistory!: (value: { messages: MessageEntry[]; taskNotifications: [] }) => void
|
||||
let resolveReload!: (value: { messages: MessageEntry[]; taskNotifications: [] }) => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { wsManager } from '../api/websocket'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { ApiResponseParseError } from '../api/client'
|
||||
import { subagentsApi } from '../api/subagents'
|
||||
import { useTeamStore } from './teamStore'
|
||||
import { useSessionStore } from './sessionStore'
|
||||
@@ -2174,6 +2175,18 @@ function sessionOwnedActivityToolUseIds(session: PerSessionState | undefined): S
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* A parse failure on a 200 has no status to show, and its raw text
|
||||
* ("Unexpected end of JSON input") says nothing a reader can act on. Say what
|
||||
* actually happened instead.
|
||||
*/
|
||||
function describeHistoryLoadError(error: unknown): string {
|
||||
if (error instanceof ApiResponseParseError) {
|
||||
return error.tooLarge ? t('session.historyTooLarge') : t('session.historyLoadFailed')
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
async function fetchAndMapSessionHistory(
|
||||
sessionId: string,
|
||||
existingOwnedToolUseIds = new Set<string>(),
|
||||
@@ -3658,7 +3671,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
return {
|
||||
sessions: updateSessionIn(state.sessions, sessionId, () => ({
|
||||
historyStatus: 'error',
|
||||
historyError: error instanceof Error ? error.message : String(error),
|
||||
historyError: describeHistoryLoadError(error),
|
||||
...pendingFailureUpdate,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Route-level invariant: the HTTP surface never inlines linked subagent tool
|
||||
* messages.
|
||||
*
|
||||
* A real session merged its 44 linked subagent transcripts (527 MB of files)
|
||||
* into one 541,817,705-byte / 539,323,608-character response, past V8's
|
||||
* 536,870,888-character string limit — where Chromium silently turns the body
|
||||
* into an empty string and the app reports `Unexpected end of JSON input`.
|
||||
* The service still merges for its own consumers (rewind, teams, workspace),
|
||||
* so the only thing keeping the collapse away is the flag the two routes pass.
|
||||
* These tests fail if that flag is dropped.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleApiRequest } from '../router.js'
|
||||
|
||||
const SUBAGENT_SENTINEL = 'SUBPAGENT_ONLY_SENTINEL_read_alpha'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
async function api(method: string, pathname: string): Promise<Response> {
|
||||
const url = new URL(pathname, 'http://localhost:3456')
|
||||
return handleApiRequest(new Request(url.toString(), { method }), url)
|
||||
}
|
||||
|
||||
async function writeJsonl(filePath: string, entries: unknown[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n',
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async function seedSessionWithSubagent(): Promise<string> {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const projectDir = '-tmp-http-invariant'
|
||||
const agentId = 'httpinvariant1'
|
||||
|
||||
await writeJsonl(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), [
|
||||
{
|
||||
type: 'file-history-snapshot',
|
||||
messageId: crypto.randomUUID(),
|
||||
snapshot: { messageId: crypto.randomUUID(), trackedFileBackups: {}, timestamp: '2026-01-01T00:00:00.000Z' },
|
||||
isSnapshotUpdate: false,
|
||||
},
|
||||
{
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'Dispatch an agent' },
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:01:00.000Z',
|
||||
userType: 'external',
|
||||
cwd: '/tmp/test',
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'Agent:0', name: 'Agent', input: { description: 'Inspect alpha' } },
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'Agent:0',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `alpha summary\nagentId: ${agentId} (use SendMessage with to: '${agentId}' to continue this agent)`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:03.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
await writeJsonl(
|
||||
path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents', `agent-${agentId}.jsonl`),
|
||||
[
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'Read:0', name: 'Read', input: { file_path: `/${SUBAGENT_SENTINEL}.txt` } },
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:04.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'Read:0', content: SUBAGENT_SENTINEL },
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
return sessionId
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-messages-http-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('session messages HTTP surface', () => {
|
||||
it('never inlines linked subagent tool messages in /messages', async () => {
|
||||
const sessionId = await seedSessionWithSubagent()
|
||||
|
||||
const response = await api('GET', `/api/sessions/${sessionId}/messages`)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const body = await response.json() as {
|
||||
messages: Array<{ parentToolUseId?: string }>
|
||||
}
|
||||
// The root Agent call is still there — only its child tool stream moved to
|
||||
// `/subagents/by-tool`. (Sidechain entries written into the root file
|
||||
// itself are not covered by this flag; they are part of root-size, not the
|
||||
// link-and-merge path this guards.)
|
||||
expect(body.messages.length).toBeGreaterThan(0)
|
||||
expect(JSON.stringify(body)).not.toContain(SUBAGENT_SENTINEL)
|
||||
expect(body.messages.some((message) => message.parentToolUseId === 'Agent:0')).toBe(false)
|
||||
})
|
||||
|
||||
it('never inlines linked subagent tool messages in the session detail', async () => {
|
||||
const sessionId = await seedSessionWithSubagent()
|
||||
|
||||
const response = await api('GET', `/api/sessions/${sessionId}`)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const body = await response.json() as {
|
||||
messages: Array<{ parentToolUseId?: string }>
|
||||
}
|
||||
expect(body.messages.length).toBeGreaterThan(0)
|
||||
expect(JSON.stringify(body)).not.toContain(SUBAGENT_SENTINEL)
|
||||
expect(body.messages.some((message) => message.parentToolUseId === 'Agent:0')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1641,6 +1641,101 @@ describe('SessionService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should omit linked subagent tool messages when the caller reads the root transcript', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const projectDir = '-tmp-project'
|
||||
const agentId = 'abc123'
|
||||
|
||||
await writeSessionFile(projectDir, sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry('Dispatch an agent'),
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'Agent:0',
|
||||
name: 'Agent',
|
||||
input: { description: 'Inspect alpha' },
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'Agent:0',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `alpha summary\nagentId: ${agentId} (use SendMessage with to: '${agentId}' to continue this agent)\n<usage>total_tokens: 10\ntool_uses: 2\nduration_ms: 30</usage>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:03.000Z',
|
||||
},
|
||||
])
|
||||
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'Read:0',
|
||||
name: 'Read',
|
||||
input: { file_path: '/tmp/alpha.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:04.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'Read:0',
|
||||
content: 'alpha body',
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:05.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const merged = await service.getSessionMessages(sessionId)
|
||||
expect(merged.filter((message) => message.parentToolUseId === 'Agent:0')).toHaveLength(2)
|
||||
|
||||
// A real session merges 500 MB+ of child tool output into this response —
|
||||
// past the 536,870,888-character limit, where Chromium hands back an empty
|
||||
// body. HTTP callers ask for the root transcript and read each run from
|
||||
// `/subagents/by-tool` instead; the server-side consumers keep the merge.
|
||||
const rootOnly = await service.getSessionMessages(sessionId, { includeSubagents: false })
|
||||
expect(rootOnly.filter((message) => message.parentToolUseId === 'Agent:0')).toHaveLength(0)
|
||||
expect(JSON.stringify(rootOnly)).toContain('"Agent:0"')
|
||||
|
||||
const detail = await service.getSession(sessionId, { includeSubagents: false })
|
||||
expect(detail?.messages.filter((message) => message.parentToolUseId === 'Agent:0'))
|
||||
.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should include linked subagent transcript changes in the message signature', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const projectDir = '-tmp-project'
|
||||
|
||||
@@ -96,8 +96,11 @@ export async function handleConversationsApi(
|
||||
// ============================================================================
|
||||
|
||||
async function sendMessage(req: Request, sessionId: string): Promise<Response> {
|
||||
// Validate session exists
|
||||
const session = await sessionService.getSession(sessionId)
|
||||
// Validate session exists. Materializing the full detail here would merge
|
||||
// every linked subagent transcript just to answer a boolean; the summary
|
||||
// reads list metadata instead (its own stream scan, but no subagent merge).
|
||||
// Same existence gate either way: both go through `findSessionFile`.
|
||||
const session = await sessionService.getSessionSummary(sessionId)
|
||||
if (!session) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
@@ -448,7 +448,10 @@ async function listSessions(req: Request, url: URL): Promise<Response> {
|
||||
}
|
||||
|
||||
async function getSession(sessionId: string): Promise<Response> {
|
||||
const detail = await sessionService.getSession(sessionId)
|
||||
// Browser/HTTP callers render the timeline, where the linked subagent tool
|
||||
// stream is fetched per Agent card. Merging it in here is what pushed a real
|
||||
// session past the 536,870,888-character response limit.
|
||||
const detail = await sessionService.getSession(sessionId, { includeSubagents: false })
|
||||
if (!detail) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
@@ -457,7 +460,7 @@ async function getSession(sessionId: string): Promise<Response> {
|
||||
|
||||
async function getSessionMessages(sessionId: string): Promise<Response> {
|
||||
const [messages, taskNotifications] = await Promise.all([
|
||||
sessionService.getSessionMessages(sessionId),
|
||||
sessionService.getSessionMessages(sessionId, { includeSubagents: false }),
|
||||
sessionService.getSessionTaskNotifications(sessionId),
|
||||
])
|
||||
return Response.json({ messages, taskNotifications })
|
||||
|
||||
@@ -238,6 +238,21 @@ export type SessionMessagesWithEvidence = {
|
||||
transcriptEvidenceComplete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers that render the session timeline want the root transcript only: the
|
||||
* linked subagent tool stream is fetched per Agent card from
|
||||
* `/subagents/by-tool`, and merging it here made one real session 542 MB —
|
||||
* past the 536,870,888-character string limit, where Chromium silently hands
|
||||
* back an empty body and the app shows `Unexpected end of JSON input`.
|
||||
*
|
||||
* Server-side consumers (rewind checkpoints, team task anchors, workspace
|
||||
* change attribution) still need the merged view, which is why the default
|
||||
* stays `true`.
|
||||
*/
|
||||
export type SessionMessagesOptions = {
|
||||
includeSubagents?: boolean
|
||||
}
|
||||
|
||||
type SubagentMessagesResult = {
|
||||
messages: MessageEntry[]
|
||||
subagentEvidenceComplete: boolean
|
||||
@@ -3829,7 +3844,10 @@ export class SessionService {
|
||||
/**
|
||||
* Get full session detail including all messages.
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<SessionDetail | null> {
|
||||
async getSession(
|
||||
sessionId: string,
|
||||
options?: SessionMessagesOptions,
|
||||
): Promise<SessionDetail | null> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) return null
|
||||
|
||||
@@ -3837,11 +3855,10 @@ export class SessionService {
|
||||
const stat = await fs.stat(filePath)
|
||||
const entries = await this.readJsonlFile(filePath)
|
||||
|
||||
const { messages } = await this.appendSubagentToolMessages(
|
||||
projectDir,
|
||||
sessionId,
|
||||
this.entriesToMessages(entries),
|
||||
)
|
||||
const rootMessages = this.entriesToMessages(entries)
|
||||
const messages = options?.includeSubagents === false
|
||||
? rootMessages
|
||||
: (await this.appendSubagentToolMessages(projectDir, sessionId, rootMessages)).messages
|
||||
const title = this.extractTitle(entries)
|
||||
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
|
||||
const permissionMode = this.resolvePermissionModeFromEntries(entries)
|
||||
@@ -3882,12 +3899,16 @@ export class SessionService {
|
||||
/**
|
||||
* Get only the messages for a session (lighter than full detail).
|
||||
*/
|
||||
async getSessionMessages(sessionId: string): Promise<MessageEntry[]> {
|
||||
return (await this.getSessionMessagesWithEvidence(sessionId)).messages
|
||||
async getSessionMessages(
|
||||
sessionId: string,
|
||||
options?: SessionMessagesOptions,
|
||||
): Promise<MessageEntry[]> {
|
||||
return (await this.getSessionMessagesWithEvidence(sessionId, options)).messages
|
||||
}
|
||||
|
||||
async getSessionMessagesWithEvidence(
|
||||
sessionId: string,
|
||||
options?: SessionMessagesOptions,
|
||||
): Promise<SessionMessagesWithEvidence> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) {
|
||||
@@ -3902,15 +3923,19 @@ export class SessionService {
|
||||
}
|
||||
|
||||
const rootTranscript = await this.readJsonlFileWithDiagnostics(found.filePath)
|
||||
const rootMessages = this.entriesToMessages(rootTranscript.entries)
|
||||
const rootEvidenceComplete = rootTranscript.exists && rootTranscript.parseComplete
|
||||
if (options?.includeSubagents === false) {
|
||||
return { messages: rootMessages, transcriptEvidenceComplete: rootEvidenceComplete }
|
||||
}
|
||||
const subagentResult = await this.appendSubagentToolMessages(
|
||||
found.projectDir,
|
||||
sessionId,
|
||||
this.entriesToMessages(rootTranscript.entries),
|
||||
rootMessages,
|
||||
)
|
||||
return {
|
||||
messages: subagentResult.messages,
|
||||
transcriptEvidenceComplete: rootTranscript.exists &&
|
||||
rootTranscript.parseComplete &&
|
||||
transcriptEvidenceComplete: rootEvidenceComplete &&
|
||||
subagentResult.subagentEvidenceComplete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,11 @@ describe('getSubagentRunByTool', () => {
|
||||
content: [{ type: 'text', text: 'Found the service seam' }],
|
||||
usage: { input_tokens: 13, output_tokens: 17 },
|
||||
})
|
||||
expect(result?.activityMessages).toEqual(result?.messages)
|
||||
// Below the truncation threshold the Activity projection IS `messages`, so
|
||||
// the server omits it rather than shipping the same array twice. The text
|
||||
// check catches a re-introduced duplicate even if the field name changes.
|
||||
expect(result?.activityMessages).toBeUndefined()
|
||||
expect(JSON.stringify(result).split('Found the service seam')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps Activity complete when the conversation projection crosses 1000 messages', async () => {
|
||||
@@ -1217,7 +1221,7 @@ describe('getSubagentRunByTool', () => {
|
||||
`${latestAgentId}/${nestedToolUseId}`,
|
||||
]
|
||||
expect(agentIds(result?.messages)).toEqual(scopedIds)
|
||||
expect(agentIds(result?.activityMessages)).toEqual(scopedIds)
|
||||
expect(agentIds(result?.activityMessages ?? result?.messages)).toEqual(scopedIds)
|
||||
const resultIds = result?.messages.flatMap((message) => (
|
||||
Array.isArray(message.content)
|
||||
? message.content.flatMap((block) => (
|
||||
|
||||
@@ -28,8 +28,16 @@ export type SubagentRunResponse = {
|
||||
outputFile?: string
|
||||
usage?: SubagentRunUsage
|
||||
messages: MessageEntry[]
|
||||
/** Complete, untruncated transcript projection used to rebuild Activity. */
|
||||
activityMessages: MessageEntry[]
|
||||
/**
|
||||
* Complete, untruncated transcript projection used to rebuild Activity.
|
||||
*
|
||||
* Only sent when `truncated` is true — that is, only when it actually
|
||||
* differs from `messages`. Below the truncation threshold the two fields held
|
||||
* the same array, so every run payload shipped its contents twice (measured:
|
||||
* exactly 2× on a synthetic fixture). Clients already fall back to
|
||||
* `messages`, which is identical in that case.
|
||||
*/
|
||||
activityMessages?: MessageEntry[]
|
||||
taskNotifications: SessionTaskNotification[]
|
||||
/** Notifications whose nested Agent ids match `activityMessages`. */
|
||||
activityTaskNotifications: SessionTaskNotification[]
|
||||
@@ -682,7 +690,7 @@ export async function getSubagentRunByAgentId(
|
||||
status,
|
||||
...(usage ? { usage } : {}),
|
||||
messages: truncated.messages,
|
||||
activityMessages: activity.messages,
|
||||
...(truncated.truncated ? { activityMessages: activity.messages } : {}),
|
||||
taskNotifications: transcript.taskNotifications,
|
||||
activityTaskNotifications: activity.taskNotifications,
|
||||
truncated: truncated.truncated,
|
||||
@@ -699,8 +707,11 @@ export async function getSubagentRunByTool(
|
||||
toolUseId: string,
|
||||
liveTaskId?: string,
|
||||
): Promise<SubagentRunResponse | null> {
|
||||
// Only the root-level `Agent` tool call is resolved here — its child
|
||||
// transcript is read separately below. Pulling the merged view instead would
|
||||
// re-materialize every linked subagent on each card open.
|
||||
const [parentMessages, taskNotifications] = await Promise.all([
|
||||
sessionService.getSessionMessages(sessionId),
|
||||
sessionService.getSessionMessages(sessionId, { includeSubagents: false }),
|
||||
sessionService.getSessionTaskNotifications(sessionId),
|
||||
])
|
||||
const resolvedToolRef = await resolveRunFromToolRef(sessionId, parentMessages, toolUseId)
|
||||
@@ -795,7 +806,7 @@ export async function getSubagentRunByTool(
|
||||
...(notification?.outputFile ? { outputFile: notification.outputFile } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
messages: truncated.messages,
|
||||
activityMessages: activity.messages,
|
||||
...(truncated.truncated ? { activityMessages: activity.messages } : {}),
|
||||
taskNotifications: transcript.taskNotifications,
|
||||
activityTaskNotifications: activity.taskNotifications,
|
||||
truncated: truncated.truncated,
|
||||
|
||||
Reference in New Issue
Block a user