diff --git a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap index 36c3ec3ed7d..d4d0da9e7c7 100644 --- a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +++ b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap @@ -526,6 +526,14 @@ exports[`IPC message snapshots ClientMessage types conversation_search serialize } `; +exports[`IPC message snapshots ClientMessage types message_content_request serializes to expected JSON 1`] = ` +{ + "messageId": "msg-001", + "sessionId": "sess-001", + "type": "message_content_request", +} +`; + exports[`IPC message snapshots ClientMessage types ipc_blob_probe serializes to expected JSON 1`] = ` { "nonceSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -1096,6 +1104,49 @@ exports[`IPC message snapshots ClientMessage types notification_intent_result se } `; +exports[`IPC message snapshots ClientMessage types recording_status serializes to expected JSON 1`] = ` +{ + "sessionId": "rec-001", + "status": "started", + "type": "recording_status", +} +`; + +exports[`IPC message snapshots ClientMessage types heartbeat_config serializes to expected JSON 1`] = ` +{ + "action": "get", + "type": "heartbeat_config", +} +`; + +exports[`IPC message snapshots ClientMessage types heartbeat_runs_list serializes to expected JSON 1`] = ` +{ + "type": "heartbeat_runs_list", +} +`; + +exports[`IPC message snapshots ClientMessage types heartbeat_run_now serializes to expected JSON 1`] = ` +{ + "type": "heartbeat_run_now", +} +`; + +exports[`IPC message snapshots ClientMessage types heartbeat_checklist_read serializes to expected JSON 1`] = ` +{ + "type": "heartbeat_checklist_read", +} +`; + +exports[`IPC message snapshots ClientMessage types heartbeat_checklist_write serializes to expected JSON 1`] = ` +{ + "content": +"- [ ] Check email +- [ ] Review PRs" +, + "type": "heartbeat_checklist_write", +} +`; + exports[`IPC message snapshots ServerMessage types auth_result serializes to expected JSON 1`] = ` { "success": true, @@ -1308,6 +1359,24 @@ exports[`IPC message snapshots ServerMessage types conversation_search_response } `; +exports[`IPC message snapshots ServerMessage types message_content_response serializes to expected JSON 1`] = ` +{ + "messageId": "msg-001", + "sessionId": "sess-001", + "text": "Full message content here", + "toolCalls": [ + { + "input": { + "command": "ls", + }, + "name": "bash", + "result": "output", + }, + ], + "type": "message_content_response", +} +`; + exports[`IPC message snapshots ServerMessage types error serializes to expected JSON 1`] = ` { "message": "Something went wrong", @@ -1360,6 +1429,7 @@ exports[`IPC message snapshots ServerMessage types model_info serializes to expe exports[`IPC message snapshots ServerMessage types history_response serializes to expected JSON 1`] = ` { + "hasMore": false, "messages": [ { "role": "user", @@ -2934,3 +3004,68 @@ exports[`IPC message snapshots ServerMessage types approved_device_remove_respon "type": "approved_device_remove_response", } `; + +exports[`IPC message snapshots ServerMessage types recording_start serializes to expected JSON 1`] = ` +{ + "recordingId": "rec-001", + "type": "recording_start", +} +`; + +exports[`IPC message snapshots ServerMessage types recording_stop serializes to expected JSON 1`] = ` +{ + "recordingId": "rec-001", + "type": "recording_stop", +} +`; + +exports[`IPC message snapshots ServerMessage types heartbeat_config_response serializes to expected JSON 1`] = ` +{ + "activeHoursEnd": 17, + "activeHoursStart": 9, + "enabled": true, + "intervalMs": 3600000, + "nextRunAt": 1700003600000, + "success": true, + "type": "heartbeat_config_response", +} +`; + +exports[`IPC message snapshots ServerMessage types heartbeat_runs_list_response serializes to expected JSON 1`] = ` +{ + "runs": [ + { + "createdAt": 1700000000000, + "id": "hb-run-001", + "result": "All systems nominal", + "title": "Morning heartbeat", + }, + ], + "type": "heartbeat_runs_list_response", +} +`; + +exports[`IPC message snapshots ServerMessage types heartbeat_run_now_response serializes to expected JSON 1`] = ` +{ + "success": true, + "type": "heartbeat_run_now_response", +} +`; + +exports[`IPC message snapshots ServerMessage types heartbeat_checklist_response serializes to expected JSON 1`] = ` +{ + "content": +"- [ ] Check email +- [ ] Review PRs" +, + "isDefault": false, + "type": "heartbeat_checklist_response", +} +`; + +exports[`IPC message snapshots ServerMessage types heartbeat_checklist_write_response serializes to expected JSON 1`] = ` +{ + "success": true, + "type": "heartbeat_checklist_write_response", +} +`; diff --git a/assistant/src/__tests__/ipc-snapshot.test.ts b/assistant/src/__tests__/ipc-snapshot.test.ts index cab43be0d9b..2df00a12e9a 100644 --- a/assistant/src/__tests__/ipc-snapshot.test.ts +++ b/assistant/src/__tests__/ipc-snapshot.test.ts @@ -340,6 +340,11 @@ const clientMessages: Record = { limit: 20, maxMessagesPerConversation: 3, }, + message_content_request: { + type: 'message_content_request', + sessionId: 'sess-001', + messageId: 'msg-001', + }, ipc_blob_probe: { type: 'ipc_blob_probe', probeId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', @@ -842,6 +847,13 @@ const serverMessages: Record = { }, ], }, + message_content_response: { + type: 'message_content_response', + sessionId: 'sess-001', + messageId: 'msg-001', + text: 'Full message content here', + toolCalls: [{ name: 'bash', result: 'output', input: { command: 'ls' } }], + }, error: { type: 'error', message: 'Something went wrong', diff --git a/assistant/src/daemon/handlers/sessions.ts b/assistant/src/daemon/handlers/sessions.ts index 4c557432226..cd3fb599fd5 100644 --- a/assistant/src/daemon/handlers/sessions.ts +++ b/assistant/src/daemon/handlers/sessions.ts @@ -21,6 +21,7 @@ import type { ConversationSearchRequest, DeleteQueuedMessage, HistoryRequest, + MessageContentRequest, RegenerateRequest, SandboxSetRequest, SecretResponse, @@ -737,17 +738,37 @@ export function handleHistoryRequest( }))) : m.surfaces; + // Apply text truncation when maxTextChars is set + let wasTruncated = false; + let text = m.text; + if (msg.maxTextChars !== undefined && text.length > msg.maxTextChars) { + text = text.slice(0, msg.maxTextChars) + ' \u2026 [truncated]'; + wasTruncated = true; + } + + // Apply tool result truncation when maxToolResultChars is set + const truncatedToolCalls = msg.maxToolResultChars !== undefined && filteredToolCalls.length > 0 + ? filteredToolCalls.map((tc) => { + if (tc.result !== undefined && tc.result.length > msg.maxToolResultChars!) { + wasTruncated = true; + return { ...tc, result: tc.result.slice(0, msg.maxToolResultChars!) + ' \u2026 [truncated]' }; + } + return tc; + }) + : filteredToolCalls; + return { ...(m.id ? { id: m.id } : {}), role: m.role, - text: m.text, + text, timestamp: m.timestamp, - ...(filteredToolCalls.length > 0 ? { toolCalls: filteredToolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}), + ...(truncatedToolCalls.length > 0 ? { toolCalls: truncatedToolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}), ...(attachments ? { attachments } : {}), ...(m.textSegments.length > 0 ? { textSegments: m.textSegments } : {}), ...(m.contentOrder.length > 0 ? { contentOrder: m.contentOrder } : {}), ...(filteredSurfaces.length > 0 ? { surfaces: filteredSurfaces } : {}), ...(m.subagentNotification ? { subagentNotification: m.subagentNotification } : {}), + ...(wasTruncated ? { wasTruncated: true } : {}), }; }); @@ -888,6 +909,45 @@ export function handleConversationSearch( }); } +export function handleMessageContentRequest( + msg: MessageContentRequest, + socket: net.Socket, + ctx: HandlerContext, +): void { + const dbMessage = conversationStore.getMessageById(msg.messageId, msg.sessionId); + if (!dbMessage) { + ctx.send(socket, { type: 'error', message: `Message ${msg.messageId} not found in session ${msg.sessionId}` }); + return; + } + + let text: string | undefined; + let toolCalls: Array<{ name: string; result?: string; input?: Record }> | undefined; + + try { + const content = JSON.parse(dbMessage.content); + const rendered = renderHistoryContent(content); + text = rendered.text || undefined; + if (rendered.toolCalls.length > 0) { + toolCalls = rendered.toolCalls.map((tc) => ({ + name: tc.name, + input: tc.input, + ...(tc.result !== undefined ? { result: tc.result } : {}), + })); + } + } catch { + // Raw text content (not JSON) + text = dbMessage.content || undefined; + } + + ctx.send(socket, { + type: 'message_content_response', + sessionId: msg.sessionId, + messageId: msg.messageId, + ...(text !== undefined ? { text } : {}), + ...(toolCalls ? { toolCalls } : {}), + }); +} + export const sessionHandlers = defineHandlers({ user_message: handleUserMessage, confirmation_response: handleConfirmationResponse, @@ -900,6 +960,7 @@ export const sessionHandlers = defineHandlers({ cancel: handleCancel, delete_queued_message: handleDeleteQueuedMessage, history_request: handleHistoryRequest, + message_content_request: handleMessageContentRequest, undo: handleUndo, regenerate: handleRegenerate, usage_request: handleUsageRequest, diff --git a/assistant/src/daemon/ipc-contract-inventory.json b/assistant/src/daemon/ipc-contract-inventory.json index 2f7a19e20f2..74e408f6c8d 100644 --- a/assistant/src/daemon/ipc-contract-inventory.json +++ b/assistant/src/daemon/ipc-contract-inventory.json @@ -101,6 +101,7 @@ "integration_list", "ipc_blob_probe", "link_open_request", + "message_content_request", "model_get", "model_set", "notification_intent_result", @@ -250,6 +251,7 @@ "memory_recalled", "memory_status", "message_complete", + "message_content_response", "message_dequeued", "message_queued", "message_queued_deleted", diff --git a/assistant/src/daemon/ipc-contract/sessions.ts b/assistant/src/daemon/ipc-contract/sessions.ts index a171bef4099..d804d730e8c 100644 --- a/assistant/src/daemon/ipc-contract/sessions.ts +++ b/assistant/src/daemon/ipc-contract/sessions.ts @@ -102,6 +102,24 @@ export interface HistoryRequest { includeSurfaceData?: boolean; /** Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. */ mode?: 'light' | 'full'; + /** Truncate message text fields beyond this character limit. When omitted, full text is returned. */ + maxTextChars?: number; + /** Truncate tool result strings beyond this character limit. When omitted, full results are returned. */ + maxToolResultChars?: number; +} + +export interface MessageContentRequest { + type: 'message_content_request'; + sessionId: string; + messageId: string; +} + +export interface MessageContentResponse { + type: 'message_content_response'; + sessionId: string; + messageId: string; + text?: string; + toolCalls?: Array<{ name: string; result?: string; input?: Record }>; } export interface UndoRequest { @@ -276,6 +294,8 @@ export interface HistoryResponse { error?: string; conversationId?: string; }; + /** True when text or tool result content was truncated due to maxTextChars/maxToolResultChars. */ + wasTruncated?: boolean; }>; /** Whether older messages exist beyond the returned page. */ hasMore: boolean; @@ -362,7 +382,8 @@ export type _SessionsClientMessages = | SessionSwitchRequest | SessionRenameRequest | SessionsClearRequest - | ConversationSearchRequest; + | ConversationSearchRequest + | MessageContentRequest; export type _SessionsServerMessages = | AuthResult @@ -381,4 +402,5 @@ export type _SessionsServerMessages = | SessionTitleUpdated | SessionListResponse | SessionsClearResponse - | ConversationSearchResponse; + | ConversationSearchResponse + | MessageContentResponse; diff --git a/assistant/src/memory/conversation-store.ts b/assistant/src/memory/conversation-store.ts index 12320cf674d..2f3bc1308c7 100644 --- a/assistant/src/memory/conversation-store.ts +++ b/assistant/src/memory/conversation-store.ts @@ -323,6 +323,21 @@ export function getMessages(conversationId: string): MessageRow[] { .map(parseMessage); } +/** Fetch a single message by ID, optionally scoped to a specific conversation. */ +export function getMessageById(messageId: string, conversationId?: string): MessageRow | null { + const db = getDb(); + const conditions = [eq(messages.id, messageId)]; + if (conversationId) { + conditions.push(eq(messages.conversationId, conversationId)); + } + const row = db + .select() + .from(messages) + .where(and(...conditions)) + .get(); + return row ? parseMessage(row) : null; +} + export interface PaginatedMessagesResult { messages: MessageRow[]; /** Whether older messages exist beyond the returned page. */ diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index 707f9ba8dac..a571daa87a2 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1956,8 +1956,12 @@ public struct IPCHistoryRequest: Codable, Sendable { public let includeSurfaceData: Bool? /// Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. public let mode: String? + /// Truncate message text fields beyond this character limit. When omitted, full text is returned. + public let maxTextChars: Double? + /// Truncate tool result strings beyond this character limit. When omitted, full results are returned. + public let maxToolResultChars: Double? - public init(type: String, sessionId: String, limit: Double? = nil, beforeTimestamp: Double? = nil, beforeMessageId: String? = nil, includeAttachments: Bool? = nil, includeToolImages: Bool? = nil, includeSurfaceData: Bool? = nil, mode: String? = nil) { + public init(type: String, sessionId: String, limit: Double? = nil, beforeTimestamp: Double? = nil, beforeMessageId: String? = nil, includeAttachments: Bool? = nil, includeToolImages: Bool? = nil, includeSurfaceData: Bool? = nil, mode: String? = nil, maxTextChars: Double? = nil, maxToolResultChars: Double? = nil) { self.type = type self.sessionId = sessionId self.limit = limit @@ -1967,6 +1971,8 @@ public struct IPCHistoryRequest: Codable, Sendable { self.includeToolImages = includeToolImages self.includeSurfaceData = includeSurfaceData self.mode = mode + self.maxTextChars = maxTextChars + self.maxToolResultChars = maxToolResultChars } } @@ -2008,8 +2014,10 @@ public struct IPCHistoryResponseMessage: Codable, Sendable { public let surfaces: [IPCHistoryResponseSurface]? /// Present when this message is a subagent lifecycle notification (completed/failed/aborted). public let subagentNotification: IPCHistoryResponseMessageSubagentNotification? + /// True when text or tool result content was truncated due to maxTextChars/maxToolResultChars. + public let wasTruncated: Bool? - public init(id: String? = nil, role: String, text: String, timestamp: Double, toolCalls: [IPCHistoryResponseToolCall]? = nil, toolCallsBeforeText: Bool? = nil, attachments: [IPCUserMessageAttachment]? = nil, textSegments: [String]? = nil, contentOrder: [String]? = nil, surfaces: [IPCHistoryResponseSurface]? = nil, subagentNotification: IPCHistoryResponseMessageSubagentNotification? = nil) { + public init(id: String? = nil, role: String, text: String, timestamp: Double, toolCalls: [IPCHistoryResponseToolCall]? = nil, toolCallsBeforeText: Bool? = nil, attachments: [IPCUserMessageAttachment]? = nil, textSegments: [String]? = nil, contentOrder: [String]? = nil, surfaces: [IPCHistoryResponseSurface]? = nil, subagentNotification: IPCHistoryResponseMessageSubagentNotification? = nil, wasTruncated: Bool? = nil) { self.id = id self.role = role self.text = text @@ -2021,6 +2029,7 @@ public struct IPCHistoryResponseMessage: Codable, Sendable { self.contentOrder = contentOrder self.surfaces = surfaces self.subagentNotification = subagentNotification + self.wasTruncated = wasTruncated } } @@ -2661,6 +2670,46 @@ public struct IPCMessageComplete: Codable, Sendable { } } +public struct IPCMessageContentRequest: Codable, Sendable { + public let type: String + public let sessionId: String + public let messageId: String + + public init(type: String, sessionId: String, messageId: String) { + self.type = type + self.sessionId = sessionId + self.messageId = messageId + } +} + +public struct IPCMessageContentResponse: Codable, Sendable { + public let type: String + public let sessionId: String + public let messageId: String + public let text: String? + public let toolCalls: [IPCMessageContentResponseToolCall]? + + public init(type: String, sessionId: String, messageId: String, text: String? = nil, toolCalls: [IPCMessageContentResponseToolCall]? = nil) { + self.type = type + self.sessionId = sessionId + self.messageId = messageId + self.text = text + self.toolCalls = toolCalls + } +} + +public struct IPCMessageContentResponseToolCall: Codable, Sendable { + public let name: String + public let result: String? + public let input: [String: AnyCodable]? + + public init(name: String, result: String? = nil, input: [String: AnyCodable]? = nil) { + self.name = name + self.result = result + self.input = input + } +} + public struct IPCMessageDequeued: Codable, Sendable { public let type: String public let sessionId: String