Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion backend/database/queries/message-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getDatabase } from '../index';
import type { DatabaseMessage } from '$shared/types/database/schema';
import type { UnifiedMessage } from '$shared/types/unified';
import { debug } from '$shared/utils/logger';
import { trimSubAgentForWire } from '$shared/utils/subagent-wire-trim';

/** Parse message JSON from a DB row. */
function parseMessage(row: DatabaseMessage): UnifiedMessage {
Expand All @@ -25,7 +26,10 @@ export const messageQueries = {
}

const path = this.getPathToRoot(session.head_message_id);
return path.map(parseMessage);
// Trim sub-agent tool noise from the display payload. The DB rows keep the
// full data; only what is sent to the frontend is slimmed. See
// trimSubAgentForWire for the rationale.
return path.map(parseMessage).map(trimSubAgentForWire);
},

/**
Expand All @@ -51,6 +55,44 @@ export const messageQueries = {
return message;
},

/**
* Get the full (untrimmed) sub-agent messages nested under a message's
* tool_use blocks, matched by `parent.toolUseId`.
*
* The wire payload trims sub-agent noise (see trimSubAgentForWire), so the
* frontend's in-memory copy is slimmed. The Debug modal uses this to fetch
* the complete sub-agent data straight from the DB and rebuild the full view.
*/
getSubAgentMessages(messageId: string): UnifiedMessage[] {
const db = getDatabase();
const row = db.prepare(`
SELECT * FROM messages WHERE id = ?
`).get(messageId) as DatabaseMessage | null;
if (!row) return [];

const parent = parseMessage(row);
if (parent.type !== 'assistant') return [];

const toolUseIds = new Set(
parent.content
.filter(block => block.type === 'tool_use')
.map(block => (block as { id: string }).id)
);
if (toolUseIds.size === 0) return [];

const rows = db.prepare(`
SELECT * FROM messages WHERE session_id = ?
ORDER BY created_at ASC
`).all(row.session_id) as DatabaseMessage[];

return rows
.map(parseMessage)
.filter(msg => {
const parentToolUseId = msg.parent?.toolUseId;
return parentToolUseId != null && toolUseIds.has(parentToolUseId);
});
},

create(messageData: {
session_id: string;
message: UnifiedMessage;
Expand Down
16 changes: 12 additions & 4 deletions backend/ws/chat/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
import { t } from 'elysia';
import { createRouter } from '$shared/utils/ws-server';
import { streamManager, type StreamEvent } from '../../chat/stream-manager';
import type { EngineType } from '$shared/types/unified';
import type { EngineType, UnifiedMessage } from '$shared/types/unified';
import { debug } from '$shared/utils/logger';
import { trimSubAgentForWire } from '$shared/utils/subagent-wire-trim';
import { ws } from '$backend/utils/ws';
import { broadcastPresence } from '../projects/status';
import { sessionQueries, messageQueries } from '../../database/queries';
Expand Down Expand Up @@ -280,7 +281,7 @@ export const streamHandler = createRouter()
case 'message': {
ws.emit.chatSession(chatSessionId, 'chat:message', {
processId: event.processId,
message: event.data.message,
message: trimSubAgentForWire(event.data.message),
usage: event.data.usage,
timestamp: event.data.timestamp,
message_id: event.data.message_id,
Expand Down Expand Up @@ -424,7 +425,7 @@ export const streamHandler = createRouter()
case 'message': {
ws.emit.chatSession(chatSessionId, 'chat:message', {
processId: event.processId,
message: event.data.message,
message: trimSubAgentForWire(event.data.message),
usage: event.data.usage,
timestamp: event.data.timestamp,
message_id: event.data.message_id,
Expand Down Expand Up @@ -618,7 +619,14 @@ export const streamHandler = createRouter()
streamId: streamState.streamId,
status: streamState.status,
processId: streamState.processId,
messages: streamState.messages,
// Trim sub-agent noise from the catch-up buffer too (see trimSubAgentForWire).
// Buffer entries wrap the UnifiedMessage under `.message`; leave the rest intact.
messages: streamState.messages.map(entry => {
const wrapped = entry as { message?: UnifiedMessage };
return wrapped?.message
? { ...wrapped, message: trimSubAgentForWire(wrapped.message) }
: entry;
}),
currentPartialText: streamState.currentPartialText,
currentReasoningText: streamState.currentReasoningText,
error: streamState.error,
Expand Down
13 changes: 13 additions & 0 deletions backend/ws/messages/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ export const crudHandler = createRouter()
return loadMessage(message);
})

// Get full (untrimmed) sub-agent messages nested under a message.
// Used by the Debug modal to show complete data despite the wire trimming
// sub-agent noise from the normal chat payload.
.http('messages:get-subagents', {
data: t.Object({
messageId: t.String({ minLength: 1 })
}),
response: t.Array(t.Any())
}, async ({ data, conn }) => {
requireMessageAccess(conn, data.messageId);
return messageQueries.getSubAgentMessages(data.messageId);
})

// Delete message
.http('messages:delete', {
data: t.Object({
Expand Down
64 changes: 62 additions & 2 deletions frontend/components/chat/modal/DebugModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@
- Display raw message data
- JSON formatting
- Copy to clipboard

The normal chat payload trims sub-agent (Task) tool noise for bandwidth
(see shared/utils/subagent-wire-trim.ts), so the in-memory message can carry
slimmed sub-agent activity. On open we fetch the full untrimmed sub-agent
messages straight from the DB and rebuild the complete subActivities so the
debug view always shows the real, complete data.
-->

<script lang="ts">
import type { FrontendMessage } from '$frontend/stores/core/sessions.svelte';
import Modal from '$frontend/components/common/overlay/Modal.svelte';
import Icon from '$frontend/components/common/display/Icon.svelte';
import ws from '$frontend/utils/ws';
import { processSubAgentMessages } from '$frontend/utils/chat/tool-handler';
import { debug } from '$shared/utils/logger';

let {
isOpen = $bindable(),
Expand All @@ -22,8 +31,59 @@
onClose: () => void;
} = $props();

// The message actually rendered — set to the in-memory copy when the modal
// opens, then replaced with a full-data clone once sub-agent messages load.
let displayMessage = $state<unknown>(undefined);

// Fetch full sub-agent data and graft complete subActivities when the modal
// opens on a message that has (possibly trimmed) sub-agent activity.
$effect(() => {
if (!isOpen) return;

const msg = message as any;
displayMessage = message;

const messageId: string | undefined = msg?.messageId;
const toolBlocks: any[] =
msg?.type === 'assistant' && Array.isArray(msg.content)
? msg.content.filter((b: any) => b?.type === 'tool_use')
: [];
const hasSubAgentActivity = toolBlocks.some(
(b: any) => Array.isArray(b.subActivities) && b.subActivities.length > 0
);

if (!messageId || !hasSubAgentActivity) return;

ws.http('messages:get-subagents', { messageId })
.then((subMessages: any) => {
if (!Array.isArray(subMessages) || subMessages.length === 0) return;

// Group full sub-agent messages by the tool_use they belong to.
const byToolUseId = new Map<string, any[]>();
for (const sub of subMessages) {
const parentToolUseId: string | undefined = sub?.parent?.toolUseId;
if (!parentToolUseId) continue;
if (!byToolUseId.has(parentToolUseId)) byToolUseId.set(parentToolUseId, []);
byToolUseId.get(parentToolUseId)!.push(sub);
}
if (byToolUseId.size === 0) return;

// Deep clone (also strips Svelte proxies) and graft full subActivities.
const clone = JSON.parse(JSON.stringify(message));
for (const block of clone.content ?? []) {
if (block?.type === 'tool_use' && byToolUseId.has(block.id)) {
block.subActivities = processSubAgentMessages(byToolUseId.get(block.id)!);
}
}
displayMessage = clone;
})
.catch((err) => {
debug.warn('chat', 'Failed to load full sub-agent data for debug:', err);
});
});

function copyToClipboard() {
navigator.clipboard.writeText(JSON.stringify(message, null, 2));
navigator.clipboard.writeText(JSON.stringify(displayMessage, null, 2));
}
</script>

Expand All @@ -42,7 +102,7 @@
Raw Message
</h4>
<div class="bg-slate-100 dark:bg-slate-800 rounded-lg p-3 border border-slate-200 dark:border-slate-700">
<pre class="text-xs font-mono text-slate-700 dark:text-slate-300 overflow-x-auto max-h-80 overflow-y-auto whitespace-pre-wrap break-words">{JSON.stringify(message, null, 2)}</pre>
<pre class="text-xs font-mono text-slate-700 dark:text-slate-300 overflow-x-auto max-h-80 overflow-y-auto whitespace-pre-wrap break-words">{JSON.stringify(displayMessage, null, 2)}</pre>
</div>
<div class="mt-3 flex justify-end">
<button
Expand Down
2 changes: 1 addition & 1 deletion frontend/utils/chat/tool-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function handleAgentTool(
}

// Process sub-agent messages into a flat activity list
function processSubAgentMessages(messages: FrontendMessage[]): SubAgentActivity[] {
export function processSubAgentMessages(messages: FrontendMessage[]): SubAgentActivity[] {
const activities: SubAgentActivity[] = [];
const toolResultMap = new Map<string, ToolResult>();

Expand Down
87 changes: 87 additions & 0 deletions shared/utils/subagent-wire-trim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Sub-agent (Task) wire payload trimming.
*
* Messages produced INSIDE a sub-agent (Task) run carry `parent.toolUseId` and
* are folded into the parent Agent tool's collapsed `subActivities` in the UI.
* That collapsed view only ever renders a tool_use's NAME plus one short brief
* field, and any text — it never renders sub-agent tool_result content, nor any
* non-brief tool_use input field. Those are pure dead weight on the wire and
* are typically the bulk of a heavy session's payload (file dumps, command
* output, Write/Edit bodies).
*
* `trimSubAgentForWire` strips that dead weight from anything SENT to the
* frontend (message read responses + live stream broadcast + catch-up buffer).
* The DB copy is never touched, so resume, branching/undo, interrupt detection,
* and the raw DebugModal (via `messages:get`) all keep the full data.
*/

import type { UnifiedMessage } from '$shared/types/unified';

/**
* The single input field the collapsed sub-agent view shows per tool.
*
* MUST stay in sync with `getToolBrief()` in the AgentTool variants
* (frontend/components/chat/tools/variants/{classic,compact}/AgentTool.svelte).
* Tools not listed show no brief, so their input is dropped entirely.
*
* Keys are unified camelCase (engines normalise snake_case → camelCase before
* persistence), so this mapping is engine-agnostic.
*/
const SUBAGENT_BRIEF_FIELD: Record<string, string> = {
Bash: 'command',
Read: 'filePath',
Write: 'filePath',
Edit: 'filePath',
Glob: 'pattern',
Grep: 'pattern',
WebFetch: 'url',
WebSearch: 'query',
};

/** Whether a message was produced inside a sub-agent (Task) run. */
function isSubAgentMessage(msg: UnifiedMessage): boolean {
return msg.parent?.toolUseId != null;
}

/**
* Return a wire-trimmed copy of a message. Non-sub-agent messages are returned
* by reference (unchanged). Sub-agent messages get a shallow copy with:
* - tool_result blocks: `content` emptied. The block, `toolUseId` and `isError`
* are kept so live waiting-input handling and interrupt detection — which key
* off the block's presence/toolUseId — behave exactly as before.
* - tool_use blocks: `input` reduced to the one displayed brief field (or `{}`
* for tools with no brief).
*/
export function trimSubAgentForWire(msg: UnifiedMessage): UnifiedMessage {
if (!isSubAgentMessage(msg)) return msg;

if (msg.type === 'user') {
let changed = false;
const content = msg.content.map(block => {
if (block.type === 'tool_result' && block.content !== '') {
changed = true;
return { ...block, content: '' };
}
return block;
});
return changed ? { ...msg, content } : msg;
}

if (msg.type === 'assistant') {
let changed = false;
const content = msg.content.map(block => {
if (block.type !== 'tool_use') return block;
const briefField = SUBAGENT_BRIEF_FIELD[block.name];
const input = block.input as Record<string, unknown>;
const trimmed =
briefField && input && briefField in input
? { [briefField]: input[briefField] }
: {};
changed = true;
return { ...block, input: trimmed } as typeof block;
});
return changed ? ({ ...msg, content } as UnifiedMessage) : msg;
}

return msg;
}
Loading