diff --git a/packages/opencode/src/session/agency-swarm-history-transport.ts b/packages/opencode/src/session/agency-swarm-history-transport.ts index 6bba602c2..243e112e5 100644 --- a/packages/opencode/src/session/agency-swarm-history-transport.ts +++ b/packages/opencode/src/session/agency-swarm-history-transport.ts @@ -5,6 +5,7 @@ import { asString, buildOutgoingMessage, buildStructuredOutgoingMessage, + collectFileURLs, isAgencyToolOutputType, normalizeCallerAgent as normalizeCallerAgentValue, stringifyToolOutput, @@ -82,6 +83,170 @@ export function buildAgencyHistoryFromMessages(input: { return input.msgs.flatMap((msg) => messageToHistoryItem(msg, input.currentID, !!input.structuredAttachments)) } +export type AgencyRunChatHistory = { + chatHistory: Array> + sessionMessages?: MessageV2.WithParts[] + compactedHistoryFromMessages?: Array> + compactedHistoryHasFileAttachments: boolean + rebuiltHistoryFromMessages?: Array> + persistRebuiltHistoryFromMessages: boolean +} + +export async function selectAgencyRunChatHistory(input: { + storedHistory: Array> + currentID: string + loadSessionMessages: () => Promise + onError?: (error: unknown) => void +}): Promise { + try { + const sessionMessages = await input.loadSessionMessages() + const compacted = compactHistory({ msgs: sessionMessages, currentID: input.currentID }) + if (compacted) { + return { + chatHistory: compacted, + sessionMessages, + compactedHistoryFromMessages: compacted, + compactedHistoryHasFileAttachments: compactHistoryHasPriorFileParts({ + msgs: sessionMessages, + currentID: input.currentID, + }), + persistRebuiltHistoryFromMessages: false, + } + } + + // Forked sessions clone local messages but get a fresh AgencySwarmHistory key, so the bridge + // would otherwise start with no context. Rebuild from cloned messages when stored history is + // empty and prior messages are all agency-swarm. + // + // Queued prompts can also be saved locally while an Agency Swarm stream is still running. + // In that window, streamed tool events are visible in local TUI state before backend + // `new_messages` are finalized, so stored AgencySwarmHistory can lag behind the visible chat. + const rebuilt = buildAgencyHistoryFromMessages({ msgs: sessionMessages, currentID: input.currentID }) + if ( + rebuilt && + rebuilt.length > 0 && + (input.storedHistory.length === 0 || + hasErroredPriorAgencyAssistant(sessionMessages, input.currentID) || + historyMissingLocalUserMessages(input.storedHistory, rebuilt)) + ) { + return { + chatHistory: rebuilt, + sessionMessages, + compactedHistoryHasFileAttachments: false, + rebuiltHistoryFromMessages: rebuilt, + persistRebuiltHistoryFromMessages: input.storedHistory.length === 0, + } + } + } catch (error) { + input.onError?.(error) + } + + return { + chatHistory: input.storedHistory, + compactedHistoryHasFileAttachments: false, + persistRebuiltHistoryFromMessages: false, + } +} + +export async function buildAgencyRunTransportPayload(input: { + history: AgencyRunChatHistory + storedHistory: Array> + currentMessage: MessageV2.WithParts + outgoingMessage: AgencyMessageInput + allowLocalFilePaths: boolean + codexTransport: boolean + materializedFilePaths: string[] + supportsStructuredAttachments: () => Promise +}): Promise<{ + message: AgencyMessageInput + chatHistory: Array> + fileURLs?: Record + replayedAttachmentKeys: Set + rebuiltHistoryForStorage?: Array> +}> { + const hasCurrentFileAttachments = hasFileParts(input.currentMessage) + const hasRebuildableFileAttachments = + input.storedHistory.length === 0 && + !!input.history.rebuiltHistoryFromMessages && + !!input.history.sessionMessages && + hasPriorFileParts(input.history.sessionMessages, input.currentMessage.info.id) + const sanitizedChatHistory = sanitizeAgencyHistoryForTransport(input.history.chatHistory, { + codexTransport: input.codexTransport, + }) + const replayOnlyOutgoing = input.allowLocalFilePaths + ? replayStoredAttachmentsInOutgoingMessage(input.outgoingMessage, sanitizedChatHistory) + : { message: input.outgoingMessage, replayedAttachmentKeys: new Set() } + const attachmentMessage = + hasCurrentFileAttachments || + hasRebuildableFileAttachments || + input.history.compactedHistoryHasFileAttachments || + messageHasAttachmentContent(replayOnlyOutgoing.message) + const structuredAttachmentsSupported = attachmentMessage && (await input.supportsStructuredAttachments()) + + let effectiveHistory = input.history.chatHistory + let rebuiltHistoryFromMessages = input.history.rebuiltHistoryFromMessages + if (structuredAttachmentsSupported && input.history.sessionMessages) { + if (input.history.compactedHistoryFromMessages) { + const compacted = compactHistory({ + msgs: input.history.sessionMessages, + currentID: input.currentMessage.info.id, + structuredAttachments: true, + }) + if (compacted) { + effectiveHistory = compacted + } + } else if (input.history.rebuiltHistoryFromMessages) { + const rebuilt = buildAgencyHistoryFromMessages({ + msgs: input.history.sessionMessages, + currentID: input.currentMessage.info.id, + structuredAttachments: true, + }) + if (rebuilt && rebuilt.length > 0) { + rebuiltHistoryFromMessages = rebuilt + effectiveHistory = rebuilt + } + } + } + + const transportChatHistory = sanitizeAgencyHistoryForTransport(effectiveHistory, { + allowLocalFilePaths: input.allowLocalFilePaths, + codexTransport: input.codexTransport, + }) + const rebuiltHistoryForStorage = + input.history.persistRebuiltHistoryFromMessages && rebuiltHistoryFromMessages + ? normalizeAgencyHistoryForStorage(rebuiltHistoryFromMessages) + : undefined + + if (structuredAttachmentsSupported) { + const outgoing = replayStoredAttachmentsInOutgoingMessage( + buildStructuredOutgoingMessage(input.currentMessage, { + allowLocalFilePaths: input.allowLocalFilePaths, + }), + transportChatHistory, + { allowLocalFilePaths: input.allowLocalFilePaths }, + ) + return { + message: outgoing.message, + chatHistory: transportChatHistory, + replayedAttachmentKeys: outgoing.replayedAttachmentKeys, + rebuiltHistoryForStorage, + } + } + + return { + message: input.outgoingMessage, + chatHistory: transportChatHistory, + fileURLs: hasCurrentFileAttachments + ? collectFileURLs(input.currentMessage, { + allowLocalFilePaths: input.allowLocalFilePaths, + materializedFilePaths: input.materializedFilePaths, + }) + : undefined, + replayedAttachmentKeys: new Set(), + rebuiltHistoryForStorage, + } +} + export function hasErroredPriorAgencyAssistant(msgs: MessageV2.WithParts[], currentID: string) { const currentIndex = msgs.findIndex((msg) => msg.info.id === currentID) const prior = currentIndex >= 0 ? msgs.slice(0, currentIndex) : msgs.filter((msg) => msg.info.id !== currentID) diff --git a/packages/opencode/src/session/agency-swarm.ts b/packages/opencode/src/session/agency-swarm.ts index 8b6cb05cc..663648bf8 100644 --- a/packages/opencode/src/session/agency-swarm.ts +++ b/packages/opencode/src/session/agency-swarm.ts @@ -12,18 +12,12 @@ import { supportsStructuredAttachmentMessages, } from "./agency-swarm-client-config" import { + buildAgencyRunTransportPayload, buildAgencyHistoryFromMessages as buildAgencyHistoryFromMessagesForTransport, compactHistory as compactHistoryForTransport, - compactHistoryHasPriorFileParts, - hasErroredPriorAgencyAssistant, - hasFileParts, - hasPriorFileParts, - historyMissingLocalUserMessages, isCodexClientConfig, - messageHasAttachmentContent, normalizeAgencyHistoryForStorage, - replayStoredAttachmentsInOutgoingMessage, - sanitizeAgencyHistoryForTransport, + selectAgencyRunChatHistory, stripReplayedAttachmentsFromMessages, } from "./agency-swarm-history-transport" import { createAgencySwarmStreamEvents } from "./agency-swarm-stream-events" @@ -31,9 +25,7 @@ import { asRecord, asString, buildOutgoingMessage, - buildStructuredOutgoingMessage, cleanupMaterializedFilePaths, - collectFileURLs, extractEventMeta, extractFunctionCallOutputs as extractFunctionCallOutputsFromMessages, findRecipientAgent, @@ -41,7 +33,6 @@ import { isAgencyAgentUpdatedHandoffMetadata, isTopLevelAgencyHandoffMetadata, normalizeCallerAgent as normalizeCallerAgentValue, - type AgencyMessageInput, type AgencySwarmEventMeta, } from "./agency-swarm-utils" @@ -361,54 +352,19 @@ export namespace SessionAgencySwarm { yield { type: "start" } yield { type: "start-step" } let streamError: Error | undefined - let compactedHistoryFromMessages: Array> | undefined - let compactedHistoryHasFileAttachments = false - let rebuiltHistoryFromMessages: Array> | undefined - let persistRebuiltHistoryFromMessages = false - let sessionMessages: MessageV2.WithParts[] | undefined const history = await AgencySwarmHistory.load(scope) - const chatHistory = await input - .loadSessionMessages() - .then((msgs) => { - sessionMessages = msgs - const compacted = compactHistory({ msgs, currentID: input.userMessage.info.id }) - if (compacted) { - compactedHistoryFromMessages = compacted - compactedHistoryHasFileAttachments = compactHistoryHasPriorFileParts({ - msgs, - currentID: input.userMessage.info.id, - }) - return compacted - } - // Forked sessions clone local messages but get a fresh AgencySwarmHistory key, so the bridge - // would otherwise start with no context. Rebuild from cloned messages when stored history is - // empty and prior messages are all agency-swarm. - // - // Queued prompts can also be saved locally while an Agency Swarm stream is still running. - // In that window, streamed tool events are visible in local TUI state before backend - // `new_messages` are finalized, so stored AgencySwarmHistory can lag behind the visible chat. - const rebuilt = buildAgencyHistoryFromMessages({ msgs, currentID: input.userMessage.info.id }) - if ( - rebuilt && - rebuilt.length > 0 && - (history.chat_history.length === 0 || - hasErroredPriorAgencyAssistant(msgs, input.userMessage.info.id) || - historyMissingLocalUserMessages(history.chat_history, rebuilt)) - ) { - rebuiltHistoryFromMessages = rebuilt - persistRebuiltHistoryFromMessages = history.chat_history.length === 0 - return rebuilt - } - return history.chat_history - }) - .catch((error) => { + const runHistory = await selectAgencyRunChatHistory({ + storedHistory: history.chat_history, + currentID: input.userMessage.info.id, + loadSessionMessages: input.loadSessionMessages, + onError: (error) => { log.warn("unable to rebuild compacted agency history; falling back to stored history", { sessionID: input.sessionID, error: error instanceof Error ? error.message : String(error), }) - return history.chat_history - }) + }, + }) const recipientAgent = await resolveRecipientAgent({ sessionID: input.sessionID, baseURL: input.options.baseURL, @@ -418,7 +374,8 @@ export namespace SessionAgencySwarm { mentionedRecipient, promptRecipient: input.recipientAgent, historyRecipient: - resolveHandoffRecipientFromHistory(chatHistory) ?? resolveHandoffRecipientFromHistory(history.chat_history), + resolveHandoffRecipientFromHistory(runHistory.chatHistory) ?? + resolveHandoffRecipientFromHistory(history.chat_history), configuredRecipient: input.options.recipientAgent, configuredRecipientSelectedAt: input.options.recipientAgentSelectedAt, loadSessionMessages: input.loadSessionMessages, @@ -442,98 +399,42 @@ export namespace SessionAgencySwarm { sessionModelSettingsExtraArgs, ) - const hasCurrentFileAttachments = hasFileParts(input.userMessage) - const hasRebuildableFileAttachments = - history.chat_history.length === 0 && - !!rebuiltHistoryFromMessages && - !!sessionMessages && - hasPriorFileParts(sessionMessages, input.userMessage.info.id) const codexTransport = isCodexClientConfig(clientConfig) const allowLocalFilePaths = isLocalAgencyURL(input.options.baseURL) - const sanitizedChatHistory = sanitizeAgencyHistoryForTransport(chatHistory, { codexTransport }) - const replayOnlyOutgoing = allowLocalFilePaths - ? replayStoredAttachmentsInOutgoingMessage(outgoingMessage, sanitizedChatHistory) - : { message: outgoingMessage, replayedAttachmentKeys: new Set() } - const attachmentMessage = - hasCurrentFileAttachments || - hasRebuildableFileAttachments || - compactedHistoryHasFileAttachments || - messageHasAttachmentContent(replayOnlyOutgoing.message) - const structuredAttachmentsSupported = - attachmentMessage && - (await supportsStructuredAttachmentMessagesForBackend({ - baseURL: input.options.baseURL, - agency, - token: input.options.token, - timeoutMs: input.options.discoveryTimeoutMs, - })) - let effectiveChatHistory = chatHistory - if (structuredAttachmentsSupported && sessionMessages) { - if (compactedHistoryFromMessages) { - const compacted = compactHistory({ - msgs: sessionMessages, - currentID: input.userMessage.info.id, - structuredAttachments: true, - }) - if (compacted) { - compactedHistoryFromMessages = compacted - effectiveChatHistory = compacted - } - } else if (rebuiltHistoryFromMessages) { - const rebuilt = buildAgencyHistoryFromMessages({ - msgs: sessionMessages, - currentID: input.userMessage.info.id, - structuredAttachments: true, - }) - if (rebuilt && rebuilt.length > 0) { - rebuiltHistoryFromMessages = rebuilt - effectiveChatHistory = rebuilt - } - } - } - - if (persistRebuiltHistoryFromMessages && rebuiltHistoryFromMessages) { - await AgencySwarmHistory.appendMessages(scope, normalizeAgencyHistoryForStorage(rebuiltHistoryFromMessages)) - } - let requestMessage: AgencyMessageInput = outgoingMessage - let fileURLs: Record | undefined try { - const transportChatHistory = sanitizeAgencyHistoryForTransport(effectiveChatHistory, { + const transport = await buildAgencyRunTransportPayload({ + history: runHistory, + storedHistory: history.chat_history, + currentMessage: input.userMessage, + outgoingMessage, allowLocalFilePaths, codexTransport, - }) - if (structuredAttachmentsSupported) { - const outgoing = replayStoredAttachmentsInOutgoingMessage( - buildStructuredOutgoingMessage(input.userMessage, { - allowLocalFilePaths, + materializedFilePaths, + supportsStructuredAttachments: () => + supportsStructuredAttachmentMessagesForBackend({ + baseURL: input.options.baseURL, + agency, + token: input.options.token, + timeoutMs: input.options.discoveryTimeoutMs, }), - transportChatHistory, - { allowLocalFilePaths }, - ) - requestMessage = outgoing.message - replayedAttachmentKeys = outgoing.replayedAttachmentKeys - } else { - replayedAttachmentKeys = new Set() - if (hasCurrentFileAttachments) { - fileURLs = collectFileURLs(input.userMessage, { - allowLocalFilePaths, - materializedFilePaths, - }) - } + }) + if (transport.rebuiltHistoryForStorage) { + await AgencySwarmHistory.appendMessages(scope, transport.rebuiltHistoryForStorage) } + replayedAttachmentKeys = transport.replayedAttachmentKeys for await (const frame of AgencySwarmAdapter.streamRun({ baseURL: input.options.baseURL, agency, - message: requestMessage, - chatHistory: transportChatHistory, + message: transport.message, + chatHistory: transport.chatHistory, recipientAgent, additionalInstructions: input.options.additionalInstructions, userContext: input.options.userContext, fileIDs: input.options.fileIDs, token: input.options.token, - fileURLs, + fileURLs: transport.fileURLs, generateChatName: input.options.generateChatName, clientConfig, abort: streamSignal,