diff --git a/src/offload/hooks/after-tool-call.test.ts b/src/offload/hooks/after-tool-call.test.ts new file mode 100644 index 0000000..8f9d004 --- /dev/null +++ b/src/offload/hooks/after-tool-call.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveAfterToolCallMessages } from "./after-tool-call.js"; + +describe("resolveAfterToolCallMessages", () => { + it("uses event messages when present", () => { + const eventMessages = [{ role: "user", content: "from event" }]; + const ctxMessages = [{ role: "user", content: "from ctx" }]; + + expect(resolveAfterToolCallMessages({ messages: eventMessages }, { messages: ctxMessages })).toEqual({ + messages: eventMessages, + source: "event.messages", + }); + }); + + it("falls back to context messages when event messages are missing", () => { + const ctxMessages = [{ role: "assistant", content: "from ctx" }]; + + expect(resolveAfterToolCallMessages({}, { messages: ctxMessages })).toEqual({ + messages: ctxMessages, + source: "ctx.messages", + }); + }); + + it("falls back to OpenClaw session params messages", () => { + const sessionMessages = [{ role: "tool", content: "result" }]; + + expect(resolveAfterToolCallMessages({}, { params: { session: { messages: sessionMessages } } })).toEqual({ + messages: sessionMessages, + source: "ctx.params.session.messages", + }); + }); + + it("prefers a non-empty fallback over an empty event messages array", () => { + const ctxMessages = [{ role: "user", content: "from ctx" }]; + + expect(resolveAfterToolCallMessages({ messages: [] }, { historyMessages: ctxMessages })).toEqual({ + messages: ctxMessages, + source: "ctx.historyMessages", + }); + }); +}); diff --git a/src/offload/hooks/after-tool-call.ts b/src/offload/hooks/after-tool-call.ts index 64e3839..ae5285b 100644 --- a/src/offload/hooks/after-tool-call.ts +++ b/src/offload/hooks/after-tool-call.ts @@ -52,6 +52,30 @@ function isHeartbeatToolCall(event: any, cachedParams: any): boolean { } } +export function resolveAfterToolCallMessages(event: any, ctx: any): { messages: any[] | undefined; source: string } { + const candidates: Array<{ source: string; value: unknown }> = [ + { source: "event.messages", value: event?.messages }, + { source: "ctx.messages", value: ctx?.messages }, + { source: "ctx.historyMessages", value: ctx?.historyMessages }, + { source: "ctx.params.session.messages", value: ctx?.params?.session?.messages }, + { source: "ctx.params.messages", value: ctx?.params?.messages }, + { source: "event.historyMessages", value: event?.historyMessages }, + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate.value) && candidate.value.length > 0) { + return { messages: candidate.value, source: candidate.source }; + } + } + + const empty = candidates.find((candidate) => Array.isArray(candidate.value)); + if (empty) { + return { messages: empty.value as any[], source: empty.source }; + } + + return { messages: undefined, source: "missing" }; +} + function _extractParamsFromMessages(messages: any[], toolCallId: string): any { if (!messages || !Array.isArray(messages) || !toolCallId) return null; const normId = toolCallId.replace(/_/g, ""); @@ -108,6 +132,15 @@ export function createAfterToolCallHandler( // rate, not just the cases where L3 actually runs. recordToolCall(); + const resolvedMessages = resolveAfterToolCallMessages(event, ctx); + if (resolvedMessages.messages && resolvedMessages.source !== "event.messages") { + event.messages = resolvedMessages.messages; + logger.debug?.( + `[context-offload] after_tool_call recovered messages from ${resolvedMessages.source} ` + + `(len=${resolvedMessages.messages.length})`, + ); + } + const eventKeys = event ? Object.keys(event) : []; const hasMsgsKey = "messages" in (event ?? {}); const msgsValue = event?.messages;