diff --git a/src/agent/loop.native-send.test.ts b/src/agent/loop.native-send.test.ts new file mode 100644 index 0000000..477b753 --- /dev/null +++ b/src/agent/loop.native-send.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { AgentLoop } from './loop.js' +import { EventQueue } from './event-queue.js' + +function makeLoop() { + const cacheDir = mkdtempSync(join(tmpdir(), 'agent-native-send-test-')) + let nextMessageId = 1 + const sendSegmentChunks = vi.fn(async (_channelId, content) => ({ + chunks: [{ id: `sent-${nextMessageId++}` }], + endCarry: [], + })) + const connector = { + sendSegmentChunks, + getBotUsername: () => 'TestBot', + } + const toolSystem = { + executeTool: vi.fn(async () => ({ output: 'tool result' })), + persistToolUse: vi.fn(async () => {}), + stripToolXml: (text: string) => text, + } + const loop = new AgentLoop( + 'test-bot', + new EventQueue(), + connector as any, + {} as any, + {} as any, + {} as any, + toolSystem as any, + cacheDir, + ) + + return { + loop, + sendSegmentChunks, + cleanup: () => rmSync(cacheDir, { recursive: true, force: true }), + } +} + +describe('AgentLoop native-tool sending', () => { + let cleanup: (() => void) | undefined + + afterEach(() => cleanup?.()) + + it('does not resend prose already flushed before native tool calls', async () => { + const harness = makeLoop() + cleanup = harness.cleanup + + ;(harness.loop as any).membraneProvider = { + stream: async (_request: unknown, callbacks: any) => { + await callbacks.onPreToolContent('First preamble. ') + await callbacks.onToolCalls( + [{ id: 'tool-1', name: 'lookup', input: { q: 'one' } }], + { depth: 0, preamble: 'First preamble. ', accumulated: 'First preamble. ' }, + ) + await callbacks.onPreToolContent('Second preamble. ') + await callbacks.onToolCalls( + [{ id: 'tool-2', name: 'lookup', input: { q: 'two' } }], + { depth: 1, preamble: 'Second preamble. ', accumulated: 'First preamble. Second preamble. ' }, + ) + + // Membrane returns every text block from the complete native tool loop, + // including both preambles delivered through onPreToolContent. + return { + content: [ + { type: 'text', text: 'First preamble. ' }, + { type: 'text', text: 'Second preamble. ' }, + { type: 'text', text: 'Final answer.' }, + ], + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 10 }, + model: 'test-model', + } + }, + } + + const result = await (harness.loop as any).executeWithNativeTools( + { stop_sequences: [] }, + { + name: 'TestBot', + continuation_model: 'test-model', + max_tool_depth: 4, + debug_thinking: false, + preserve_thinking_blocks: false, + }, + 'channel-1', + 'trigger-1', + ) + + expect(harness.sendSegmentChunks.mock.calls.map((call) => call[1])).toEqual([ + 'First preamble.', + 'Second preamble.', + 'Final answer.', + ]) + expect(result.completion.content).toEqual([ + { type: 'text', text: 'First preamble. Second preamble. Final answer.' }, + ]) + expect(result.preambleMessageIds).toEqual(['sent-1', 'sent-2']) + expect(result.sentMessageIds).toEqual(['sent-1', 'sent-2', 'sent-3']) + expect((harness.loop as any).toolSystem.persistToolUse.mock.calls.map((call: any[]) => call[2].originalCompletionText)).toEqual([ + 'First preamble. ', + 'Second preamble. ', + ]) + }) +}) diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 97fab4d..0020e74 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -9,6 +9,7 @@ import { ChannelStateManager } from './state-manager.js' import { DiscordConnector, type PinnedSteer } from '../discord/connector.js' import { ConfigSystem } from '../config/system.js' import { ContextBuilder, BuildContextParams } from '../context/builder.js' +import { collectCoveredToolMessageIds } from '../context/stages/tool-interleave.js' import { ToolSystem } from '../tools/system.js' import { Event, BotConfig, ContentBlock, DiscordMessage, ToolCall, ToolResult, VendorConfig } from '../types.js' import { logger, withActivationLogging } from '../utils/logger.js' @@ -1775,16 +1776,14 @@ export class AgentLoop { }, 'Updated visible images with cached MCP results') } - // 4c. Filter out Discord messages that are in tool cache's botMessageIds + // 4c. Filter only Discord messages whose text the tool cache reconstructs // ONLY when preserve_thinking_context is DISABLED // When enabled, the activation store handles full completions and needs the original messages if (!config.preserve_thinking_context) { - const toolCacheBotMessageIds = new Set() - for (const entry of toolCacheForContext) { - if (entry.call.botMessageIds) { - entry.call.botMessageIds.forEach(id => toolCacheBotMessageIds.add(id)) - } - } + const toolCacheBotMessageIds = collectCoveredToolMessageIds( + toolCacheForContext, + discordContext.messages + ) if (toolCacheBotMessageIds.size > 0) { const beforeFilter = discordContext.messages.length @@ -2093,7 +2092,8 @@ export class AgentLoop { } // Combine text message IDs (already sent by inline execution) with image IDs - const allMessageIds = [...(inlineSentMessageIds ?? []), ...imageSentIds] + const textMessageIds = inlineSentMessageIds ?? [] + const allMessageIds = [...new Set([...textMessageIds, ...imageSentIds])] const responseText = completion.content .filter((c: any) => c.type === 'text') .map((c: any) => c.text) @@ -2118,6 +2118,21 @@ export class AgentLoop { await this.activationStore.completeActivation(activation.id) } + // This branch returns before normal response finalization below, so it + // must also attach liveness and reconstruction metadata to tool entries. + const coveredMessageIds = toolMode === 'native' + ? [...new Set(preambleMessageIds)] + : [...new Set(textMessageIds)] + if (toolCallIds.length > 0 && allMessageIds.length > 0) { + await this.toolSystem.updateBotMessageIds( + this.botId, + channelId, + toolCallIds, + allMessageIds, + coveredMessageIds + ) + } + // Update state and trace for image response if (contextResult.cacheMarker) { this.stateManager.updateCacheMarker(this.botId, channelId, contextResult.cacheMarker) @@ -2206,11 +2221,21 @@ export class AgentLoop { await this.activationStore.completeActivation(activation.id) } - // Update tool cache entries with bot message IDs (for existence checking on reload) - // Include both preamble message IDs and final response message IDs - const allBotMessageIds = [...preambleMessageIds, ...sentMessageIds] + // Keep all emitted messages as liveness anchors, but only suppress Discord + // messages that the cached completion actually reconstructs. Native tool + // entries retain per-round preambles, not their post-tool final answer. + const allBotMessageIds = [...new Set(sentMessageIds)] + const coveredMessageIds = toolMode === 'native' + ? [...new Set(preambleMessageIds)] + : allBotMessageIds if (toolCallIds.length > 0 && allBotMessageIds.length > 0) { - await this.toolSystem.updateBotMessageIds(this.botId, channelId, toolCallIds, allBotMessageIds) + await this.toolSystem.updateBotMessageIds( + this.botId, + channelId, + toolCallIds, + allBotMessageIds, + coveredMessageIds + ) } // 9. Update state @@ -2501,9 +2526,14 @@ export class AgentLoop { }> { const allToolCallIds: string[] = [] const allSentMessageIds: string[] = [] + const allPreambleMessageIds: string[] = [] const messageContexts: Record = {} const pendingToolPersistence: Array<{ call: ToolCall; result: ToolResult }> = [] let accumulatedPreToolText = '' + // Membrane's final native response includes all text from every tool round, + // including prose already delivered through onPreToolContent. Keep the raw + // flushed prefix so the final Discord send can advance past it exactly once. + let flushedPreToolText = '' // Open markdown construct carried across this activation's messages // (pre-tool flushes + final send). let markdownCarry: MarkdownCarry = [] @@ -2595,10 +2625,12 @@ export class AgentLoop { ) markdownCarry = sendResult.endCarry allSentMessageIds.push(...sendResult.sentMessageIds) + allPreambleMessageIds.push(...sendResult.sentMessageIds) for (const [msgId, ctx] of Object.entries(sendResult.messageContexts)) { messageContexts[msgId] = ctx } // Reset so we don't re-send + flushedPreToolText += accumulatedPreToolText accumulatedPreToolText = '' } }, @@ -2622,7 +2654,10 @@ export class AgentLoop { input: call.input as Record, messageId: triggeringMessageId, timestamp: new Date(), - originalCompletionText: context.accumulated || '', + // Membrane's accumulated field contains prose from every native + // tool round. Cache only this round's preamble or later context + // reconstruction repeats earlier prose. + originalCompletionText: context.preamble || '', } const toolResult = await this.toolSystem.executeTool(cxCall) @@ -2724,6 +2759,22 @@ export class AgentLoop { .map((c: any) => c.text) .join('') + // onPreToolContent is a preview callback, not a destructive read: the + // same text is present at the front of the final aggregate response. + // Advance the display cursor past successfully flushed prose. Be + // conservative if a future Membrane version changes that contract. + let remainingCompletionText = completionText + if (flushedPreToolText) { + if (completionText.startsWith(flushedPreToolText)) { + remainingCompletionText = completionText.slice(flushedPreToolText.length) + } else { + logger.warn({ + completionLength: completionText.length, + flushedLength: flushedPreToolText.length, + }, 'Native tool response did not contain the flushed pre-tool prefix') + } + } + // Capture generated image blocks (from image generation models like Gemini) const generatedImageBlocks: ContentBlock[] = (result?.content || []) .filter((c: any) => c.type === 'image') @@ -2738,6 +2789,9 @@ export class AgentLoop { const { stripped, content: textThinkingContent } = this.stripThinkingBlocks( this.toolSystem.stripToolXml(completionText) ) + const { stripped: remainingStripped } = this.stripThinkingBlocks( + this.toolSystem.stripToolXml(remainingCompletionText) + ) // Thinking for debug display: structured blocks (native thinking) plus // any literal text the model wrote (legacy/prefill style) @@ -2772,6 +2826,7 @@ export class AgentLoop { // Truncate at participant names let displayText = stripped + let remainingDisplayText = remainingStripped if (discordMessages) { const truncResult = this.truncateAtParticipant( displayText, @@ -2784,16 +2839,28 @@ export class AgentLoop { logger.info({ truncatedAt: truncResult.truncatedAt }, 'Truncated native output at participant') displayText = truncResult.text } + + const remainingTruncResult = this.truncateAtParticipant( + remainingDisplayText, + discordMessages, + this.connector.getBotUsername() || config.name, + llmRequest.stop_sequences, + config + ) + if (remainingTruncResult.truncatedAt) { + remainingDisplayText = remainingTruncResult.text + } } // Replace mentions if (discordMessages) { displayText = await this.replaceMentions(displayText, discordMessages) + remainingDisplayText = await this.replaceMentions(remainingDisplayText, discordMessages) } // Send remaining text to Discord (text not already sent via onPreToolContent) - if (displayText.trim()) { - const segments = this.parseIntoSegments(displayText) + if (remainingDisplayText.trim()) { + const segments = this.parseIntoSegments(remainingDisplayText) if (segments.length > 0) { const sendResult = await this.sendSegments( channelId, @@ -2844,7 +2911,7 @@ export class AgentLoop { raw: result?.raw ?? null, }, toolCallIds: allToolCallIds, - preambleMessageIds: [], + preambleMessageIds: allPreambleMessageIds, fullCompletionText: completionText, sentMessageIds: allSentMessageIds, messageContexts, @@ -2876,7 +2943,7 @@ export class AgentLoop { model: 'interrupted', }, toolCallIds: allToolCallIds, - preambleMessageIds: [], + preambleMessageIds: allPreambleMessageIds, fullCompletionText: ttsCtx.interruptedText, sentMessageIds: allSentMessageIds, messageContexts, @@ -4041,4 +4108,3 @@ export class AgentLoop { ) } } - diff --git a/src/context/builder.test.ts b/src/context/builder.test.ts index 046f914..3f81901 100644 --- a/src/context/builder.test.ts +++ b/src/context/builder.test.ts @@ -22,7 +22,11 @@ import { extractModelConfig, } from './stages/finalize.js' import { applyLimits } from './stages/cache-and-limits.js' -import { formatToolUseWithResults } from './stages/tool-interleave.js' +import { + collectCoveredToolMessageIds, + formatToolUseWithResults, + interleaveToolMessages, +} from './stages/tool-interleave.js' import { applyMentionFormat } from './stages/mentions.js' import { injectActivationCompletions, @@ -664,6 +668,76 @@ describe('mergeConsecutiveParticipantMessages', () => { }) }) +// ============================================================================ +// Unit tests: collectCoveredToolMessageIds +// ============================================================================ + +describe('collectCoveredToolMessageIds', () => { + it('keeps a legacy native-tool final answer when only the preamble was cached', () => { + const messages = [ + makeDiscordMessage({ id: 'msg-trigger', content: 'respond and save the note' }), + makeDiscordMessage({ id: 'preamble', content: ' Let me save that note first.' }), + makeDiscordMessage({ id: 'final', content: 'rat — malformed thoughts becoming bird flu.' }), + ] + const toolCache = [{ + call: makeToolCall({ + originalCompletionText: 'Let me save that note first.', + botMessageIds: ['preamble', 'final'], + }), + result: 'saved', + }] + + expect([...collectCoveredToolMessageIds(toolCache, messages)]).toEqual(['preamble']) + }) + + it('uses explicit coverage, including an intentionally empty subset', () => { + const messages = [ + makeDiscordMessage({ id: 'trigger', content: 'please save this' }), + makeDiscordMessage({ id: 'preamble', content: 'preamble' }), + makeDiscordMessage({ id: 'final', content: 'final answer' }), + ] + const explicit = [{ + call: makeToolCall({ + messageId: 'trigger', + originalCompletionText: 'different text', + botMessageIds: ['preamble', 'final'], + coveredMessageIds: ['preamble'], + }), + result: 'saved', + }] + const empty = [{ + call: makeToolCall({ + messageId: 'trigger', + originalCompletionText: 'preamble final answer', + botMessageIds: ['preamble', 'final'], + coveredMessageIds: [], + }), + result: 'saved', + }] + + expect([...collectCoveredToolMessageIds(explicit, messages)]).toEqual(['preamble']) + expect([...collectCoveredToolMessageIds(empty, messages)]).toEqual([]) + }) + + it('keeps covered Discord text when the cache insertion anchor is absent', () => { + const messages = [ + makeDiscordMessage({ id: 'preamble', content: 'preamble' }), + makeDiscordMessage({ id: 'final', content: 'final answer' }), + ] + const toolCache = [{ + call: makeToolCall({ + messageId: 'deleted-trigger', + originalCompletionText: 'preamble', + botMessageIds: ['preamble', 'final'], + coveredMessageIds: ['preamble'], + }), + result: 'saved', + }] + + expect([...collectCoveredToolMessageIds(toolCache, messages)]).toEqual([]) + }) +}) + // ============================================================================ // Unit tests: formatToolUseWithResults // ============================================================================ @@ -713,6 +787,44 @@ describe('formatToolUseWithResults', () => { expect(toolResult.content[1].type).toBe('image') }) + it('emits a shared completion once for several tool results', () => { + const sharedCall = { + messageId: 'trigger-msg', + originalCompletionText: 'search and fetch', + } + const toolCache = [ + { + call: makeToolCall({ ...sharedCall, name: 'search' }), + result: { output: 'found result' }, + }, + { + call: makeToolCall({ ...sharedCall, name: 'fetch' }), + result: { output: 'fetched page' }, + }, + ] + + const formatted = formatToolUseWithResults(toolCache, 'TestBot') + expect(formatted.map(message => message.participant)).toEqual([ + 'TestBot', + 'System<[search]', + 'System<[fetch]', + ]) + + const trigger = makeParticipantMessage({ messageId: 'trigger-msg' }) + expect(interleaveToolMessages([trigger], formatted)).toEqual([trigger, ...formatted]) + }) + + it('restores cached tool errors as error text', () => { + const toolCache = [{ + call: makeToolCall({ name: 'fetch' }), + result: { output: null, error: 'connection failed' }, + }] + + const result = formatToolUseWithResults(toolCache, 'TestBot') + + expect(textOf(result[1])).toBe('Error executing fetch: connection failed') + }) + it('handles non-string non-object results', () => { const toolCache = [ { diff --git a/src/context/stages/tool-interleave.ts b/src/context/stages/tool-interleave.ts index ec7ef1c..67b1fa4 100644 --- a/src/context/stages/tool-interleave.ts +++ b/src/context/stages/tool-interleave.ts @@ -7,33 +7,96 @@ import type { ContentBlock, ImageContent, ToolCall, + DiscordMessage, } from '../../types.js' import { logger } from '../../utils/logger.js' +/** Normalize visible Discord text for legacy tool-cache coverage inference. */ +function normalizeVisibleToolText(text: string): string { + return text + .replace(/^\s*]+>\s*/, '') + .replace(/[\s\S]*?<\/thinking>/g, '') + .replace(/[\s\S]*?<\/function_calls>/g, '') + .replace(/[\s\S]*?<\/function_results>/g, '') + .replace(/System:\s*[\s\S]*?<\/results>/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Find Discord messages that are genuinely reconstructed by cached tool-call + * completion text. + * + * New cache entries state this explicitly via coveredMessageIds. For older + * entries, infer conservatively by checking that the visible Discord text is + * actually present in originalCompletionText. This repairs old native-tool + * entries that incorrectly marked their post-tool final answer as covered even + * though the cache retained only the pre-tool preamble. + */ +export function collectCoveredToolMessageIds( + toolCacheWithResults: Array<{ call: ToolCall; result: any }>, + discordMessages: DiscordMessage[] +): Set { + const covered = new Set() + const messagesById = new Map(discordMessages.map(message => [message.id, message])) + + for (const entry of toolCacheWithResults) { + // interleaveToolMessages inserts cached exchanges after their triggering + // message. If that anchor was deleted, filtered, or fell outside the current + // history window, suppressing the surviving Discord response would lose it. + if (!messagesById.has(entry.call.messageId)) continue + + const explicitIds = entry.call.coveredMessageIds + if (explicitIds !== undefined) { + const botMessageIds = new Set(entry.call.botMessageIds || []) + for (const id of explicitIds) { + if (botMessageIds.has(id) && messagesById.has(id)) covered.add(id) + } + continue + } + + const completionText = normalizeVisibleToolText(entry.call.originalCompletionText || '') + if (!completionText) continue + + for (const id of entry.call.botMessageIds || []) { + const message = messagesById.get(id) + if (!message) continue + const visibleText = normalizeVisibleToolText(message.content || '') + if (visibleText && completionText.includes(visibleText)) { + covered.add(id) + } + } + } + + return covered +} + /** - * Format tool cache entries as paired bot-completion + system-result messages. - * Each entry produces two ParticipantMessages: the bot's original completion text - * and the system tool result. + * Format tool cache entries as bot-completion and system-result messages. + * Several tools can share one model completion, so identical adjacent completion + * text is emitted once followed by each distinct result. */ export function formatToolUseWithResults( toolCacheWithResults: Array<{call: ToolCall, result: any}>, botName: string ): ParticipantMessage[] { const messages: ParticipantMessage[] = [] + let previousCompletionKey: string | undefined for (const entry of toolCacheWithResults) { - // Bot's message with original completion text (includes XML tool call) - messages.push({ - participant: botName, - content: [ - { - type: 'text', - text: entry.call.originalCompletionText, - }, - ], - timestamp: entry.call.timestamp, - messageId: entry.call.messageId, - }) + const completionText = entry.call.originalCompletionText || '' + const completionKey = `${entry.call.messageId}\0${completionText}` + if (completionText && completionKey !== previousCompletionKey) { + // Bot's message with original completion text (includes XML tool call for + // inline tools; native entries contain their visible pre-tool preamble). + messages.push({ + participant: botName, + content: [{ type: 'text', text: completionText }], + timestamp: entry.call.timestamp, + messageId: entry.call.messageId, + }) + } + previousCompletionKey = completionKey // Tool result message from SYSTEM (not bot) // Result can be: string (legacy), { output, images } (new format), or other object @@ -45,7 +108,11 @@ export function formatToolUseWithResults( } else if (entry.result && typeof entry.result === 'object') { // New format with output and optional images const output = entry.result.output - const outputText = typeof output === 'string' ? output : JSON.stringify(output) + const outputText = entry.result.error + ? `Error executing ${entry.call.name}: ${entry.result.error}` + : typeof output === 'string' + ? output + : JSON.stringify(output) ?? String(output) resultContent.push({ type: 'text', text: outputText }) // Add MCP images to context @@ -80,7 +147,7 @@ export function formatToolUseWithResults( /** * Interleave tool messages with participant messages based on messageId. - * Tool call/result pairs are inserted after the message that triggered them. + * Cached tool messages are inserted after the message that triggered them. */ export function interleaveToolMessages( participantMessages: ParticipantMessage[], @@ -88,16 +155,12 @@ export function interleaveToolMessages( ): ParticipantMessage[] { // Create a map of triggering message ID -> tool messages const toolsByMessageId = new Map() - for (let i = 0; i < toolMessages.length; i += 2) { - const toolCall = toolMessages[i] - const toolResult = toolMessages[i + 1] - if (toolCall && toolResult) { - const messageId = toolCall.messageId || '' - if (!toolsByMessageId.has(messageId)) { - toolsByMessageId.set(messageId, []) - } - toolsByMessageId.get(messageId)!.push(toolCall, toolResult) + for (const toolMessage of toolMessages) { + const messageId = toolMessage.messageId || '' + if (!toolsByMessageId.has(messageId)) { + toolsByMessageId.set(messageId, []) } + toolsByMessageId.get(messageId)!.push(toolMessage) } // Interleave tools with messages based on messageId diff --git a/src/llm/membrane/provider.ts b/src/llm/membrane/provider.ts index 12e45ed..b41883f 100644 --- a/src/llm/membrane/provider.ts +++ b/src/llm/membrane/provider.ts @@ -637,7 +637,7 @@ export interface StreamOptions { /** Called when tool calls are detected */ onToolCalls?: ( calls: Array<{ id: string; name: string; input: Record }>, - context: { depth: number; accumulated: string } + context: { depth: number; preamble?: string; accumulated: string } ) => Promise>; /** Called with pre-tool content before executing tools */ @@ -665,4 +665,3 @@ export interface StreamOptions { */ formatterOverride?: 'native' | 'anthropic-xml' | 'completions'; } - diff --git a/src/tools/system.cache.test.ts b/src/tools/system.cache.test.ts new file mode 100644 index 0000000..227d103 --- /dev/null +++ b/src/tools/system.cache.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { ToolSystem } from './system.js' +import type { ToolCall, ToolResult } from '../types.js' + +describe('ToolSystem cache message coverage', () => { + const temporaryDirectories: string[] = [] + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('persists liveness anchors separately from reconstructed Discord messages', async () => { + const cacheDirectory = mkdtempSync(join(tmpdir(), 'chapterx-tool-cache-')) + temporaryDirectories.push(cacheDirectory) + const toolSystem = new ToolSystem(cacheDirectory) + const call: ToolCall = { + id: 'call-1', + name: 'notes_save', + input: { text: 'remember this' }, + messageId: 'trigger', + timestamp: new Date('2026-09-01T12:00:00Z'), + originalCompletionText: 'I will save that note.', + } + const result: ToolResult = { + callId: call.id, + output: 'saved', + timestamp: new Date('2026-09-01T12:00:01Z'), + } + + await toolSystem.persistToolUse('bot', 'channel', call, result) + await toolSystem.updateBotMessageIds( + 'bot', + 'channel', + [call.id], + ['preamble', 'final-answer'], + ['preamble'] + ) + + const [cached] = await toolSystem.loadCacheWithResults( + 'bot', + 'channel', + new Set(['preamble', 'final-answer']) + ) + + expect(cached?.call.botMessageIds).toEqual(['preamble', 'final-answer']) + expect(cached?.call.coveredMessageIds).toEqual(['preamble']) + }) + + it('restores persisted tool errors', async () => { + const cacheDirectory = mkdtempSync(join(tmpdir(), 'chapterx-tool-cache-')) + temporaryDirectories.push(cacheDirectory) + const toolSystem = new ToolSystem(cacheDirectory) + const call: ToolCall = { + id: 'call-error', + name: 'fetch', + input: {}, + messageId: 'trigger', + timestamp: new Date('2026-09-01T12:00:00Z'), + originalCompletionText: 'Fetching.', + botMessageIds: ['response'], + } + const result: ToolResult = { + callId: call.id, + output: null, + error: 'connection failed', + timestamp: new Date('2026-09-01T12:00:01Z'), + } + + await toolSystem.persistToolUse('bot', 'channel', call, result) + const [cached] = await toolSystem.loadCacheWithResults( + 'bot', + 'channel', + new Set(['response']) + ) + + expect(cached?.result.error).toBe('connection failed') + }) +}) diff --git a/src/tools/system.ts b/src/tools/system.ts index 701d8dd..e53a330 100644 --- a/src/tools/system.ts +++ b/src/tools/system.ts @@ -302,7 +302,9 @@ export class ToolSystem { // Truncate large results to prevent context bloat let result = entry.result.output - const resultStr = typeof result === 'string' ? result : JSON.stringify(result) + const resultStr = typeof result === 'string' + ? result + : JSON.stringify(result) ?? String(result) if (resultStr.length > 2000) { result = resultStr.substring(0, 2000) + '\n...[truncated]' } @@ -316,9 +318,13 @@ export class ToolSystem { timestamp: new Date(entry.timestamp), originalCompletionText: entry.call.originalCompletionText || '', botMessageIds: botMsgIds, + coveredMessageIds: Array.isArray(entry.call.coveredMessageIds) + ? entry.call.coveredMessageIds + : undefined, }, result: { output: result, + error: entry.result.error, images: entry.result.images, // Restore MCP images from cache } }) @@ -722,6 +728,7 @@ export class ToolSystem { messageId: call.messageId, originalCompletionText: call.originalCompletionText, botMessageIds: call.botMessageIds, // May be undefined, will be updated later + coveredMessageIds: call.coveredMessageIds, // May be undefined, will be updated later }, result: { output: result.output, @@ -740,14 +747,15 @@ export class ToolSystem { } /** - * Update tool cache entries with bot message IDs - * Called after activation completes and we know which Discord messages were sent + * Update tool cache entries with liveness anchors and the subset of Discord + * messages whose visible text is reconstructed by originalCompletionText. */ async updateBotMessageIds( botId: string, channelId: string, toolCallIds: string[], - botMessageIds: string[] + botMessageIds: string[], + coveredMessageIds: string[] = botMessageIds ): Promise { if (toolCallIds.length === 0 || botMessageIds.length === 0) return @@ -766,10 +774,20 @@ export class ToolSystem { const updatedLines = lines.map(line => { try { const entry = JSON.parse(line) - if (toolCallIds.includes(entry.call?.id) && !entry.call?.botMessageIds) { - entry.call.botMessageIds = botMessageIds - modified = true - return JSON.stringify(entry) + if (toolCallIds.includes(entry.call?.id)) { + let changed = false + if (!entry.call?.botMessageIds) { + entry.call.botMessageIds = botMessageIds + changed = true + } + if (!Array.isArray(entry.call?.coveredMessageIds)) { + entry.call.coveredMessageIds = coveredMessageIds + changed = true + } + if (changed) { + modified = true + return JSON.stringify(entry) + } } return line } catch { @@ -924,4 +942,3 @@ export class ToolSystem { } } } - diff --git a/src/types.ts b/src/types.ts index 3773a8c..824e6e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -422,7 +422,14 @@ export interface ToolCall { messageId: string // For pruning old calls (triggering user message) timestamp: Date originalCompletionText: string // The bot's original text including XML tool call - botMessageIds?: string[] // Discord message IDs from bot's response (for existence checking) + /** All Discord messages emitted by the activation (tool-cache liveness anchors). */ + botMessageIds?: string[] + /** + * Subset of botMessageIds whose visible text is reconstructed by + * originalCompletionText. Only these messages may be removed from raw Discord + * context before the cached tool exchange is interleaved. + */ + coveredMessageIds?: string[] } export interface ToolCallWithResult { @@ -614,4 +621,3 @@ export class ToolError extends Chapter3Error { this.name = 'ToolError' } } -