diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 9031c671da25..301347cb6c1c 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1417,7 +1417,6 @@ packages/acp-core/src/types.ts 2 packages/agent-core/src/agent-loop.ts 3 packages/agent-core/src/harness/compaction/branch-summarization.ts 1 packages/agent-core/src/harness/compaction/compaction.ts 2 -packages/agent-core/src/harness/messages.ts 2 packages/agent-core/src/harness/session/tool-result-pairing.ts 17 packages/agent-core/src/harness/session/uuid.ts 1 packages/agent-core/src/harness/utils/truncate.ts 1 diff --git a/packages/agent-core/src/harness/messages.test.ts b/packages/agent-core/src/harness/messages.test.ts index 7926242ec3c9..2140d02696a1 100644 --- a/packages/agent-core/src/harness/messages.test.ts +++ b/packages/agent-core/src/harness/messages.test.ts @@ -1,7 +1,103 @@ // Agent Core tests cover messages behavior. +import type { Message } from "@openclaw/llm-core"; import { describe, expect, it } from "vitest"; +import type { AgentMessage } from "../types.js"; import { convertToLlm, createCustomMessage } from "./messages.js"; +describe("convertToLlm message ownership", () => { + it("preserves standard message objects and their private metadata", () => { + const user: Message = { role: "user", content: "question", timestamp: 1 }; + const identity = Symbol("message identity"); + Object.defineProperty(user, identity, { value: "original", enumerable: false }); + const messages: Message[] = [ + user, + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + api: "openai-responses", + provider: "openai", + model: "gpt-5.6-sol", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call", + toolName: "fixture", + content: [{ type: "text", text: "result" }], + isError: false, + timestamp: 3, + }, + ]; + const converted = convertToLlm(messages); + + expect(converted).not.toBe(messages); + expect(converted).toHaveLength(messages.length); + messages.forEach((message, index) => expect(converted[index]).toBe(message)); + expect(Object.getOwnPropertyDescriptor(converted[0], identity)).toEqual( + Object.getOwnPropertyDescriptor(user, identity), + ); + }); + + it.each([false, true])("preserves custom content ownership with carrier=%s", (carrier) => { + const timestamp = "2026-05-30T17:00:00.000Z"; + const blocks = [{ type: "text" as const, text: "array content" }]; + const customType = carrier ? "openclaw.runtime-context" : "note"; + const details = carrier + ? { source: "openclaw-runtime-context", runtimeContextCarrier: true } + : { source: "other" }; + const arrayMessage = createCustomMessage(customType, blocks, false, details, timestamp); + const textMessage = createCustomMessage(customType, "text content", false, details, timestamp); + const [array, text] = convertToLlm([arrayMessage, textMessage]); + const [repeatedText] = convertToLlm([textMessage]); + + expect(array).not.toBe(arrayMessage); + expect(array?.content).toBe(blocks); + expect(array).toEqual({ + role: "user", + content: blocks, + timestamp: Date.parse(timestamp), + ...(carrier ? { runtimeContextCarrier: true } : {}), + }); + expect(text).not.toBe(textMessage); + expect(text).toEqual({ + role: "user", + content: [{ type: "text", text: "text content" }], + timestamp: Date.parse(timestamp), + ...(carrier ? { runtimeContextCarrier: true } : {}), + }); + expect(text?.content).not.toBe(repeatedText?.content); + }); + + it("skips array holes and does not visit messages appended during conversion", () => { + const messages: AgentMessage[] = []; + messages.length = 3; + const appended: AgentMessage = { role: "user", content: "later", timestamp: 2 }; + const first: AgentMessage = { + get role() { + messages.push(appended); + return "user" as const; + }, + content: "first", + timestamp: 1, + }; + messages[1] = first; + const converted = convertToLlm(messages); + + expect(converted).toHaveLength(1); + expect(converted[0]).toBe(first); + expect(messages).toHaveLength(4); + }); +}); + describe("harness message timestamps", () => { it("rejects invalid timestamps before creating context messages", () => { expect(() => createCustomMessage("note", "content", true, {}, "not-a-date")).toThrow( @@ -55,31 +151,3 @@ describe("harness message timestamps", () => { expect(message?.timestamp).toBe(0); }); }); - -describe("convertToLlm runtime-context carrier marking", () => { - const timestamp = "2026-05-30T17:00:00.000Z"; - - it("marks a runtime-context carrier custom message so providers skip cache anchoring", () => { - const [message] = convertToLlm([ - createCustomMessage( - "openclaw.runtime-context", - "current-turn metadata", - false, - { source: "openclaw-runtime-context", runtimeContextCarrier: true }, - timestamp, - ), - ]); - - expect(message?.role).toBe("user"); - expect((message as { runtimeContextCarrier?: boolean }).runtimeContextCarrier).toBe(true); - }); - - it("does not mark ordinary custom messages", () => { - const [message] = convertToLlm([ - createCustomMessage("note", "some note", false, { source: "other" }, timestamp), - ]); - - expect(message?.role).toBe("user"); - expect((message as { runtimeContextCarrier?: boolean }).runtimeContextCarrier).toBeUndefined(); - }); -}); diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts index cfdb380122f6..ec4e7b58594c 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -17,17 +17,12 @@ export type { } from "../types.js"; /** Harness-only transcript entries that can be normalized into LLM messages. */ -export type HarnessMessage = - | AgentMessage - | BashExecutionMessage - | CustomMessage - | BranchSummaryMessage - | CompactionSummaryMessage; +export type HarnessMessage = AgentMessage; // Internal session paths keep call sites explicit about this harness-owned // boundary even though these message roles are part of AgentMessage. export function asAgentMessage(message: HarnessMessage): AgentMessage { - return message as AgentMessage; + return message; } function requireSessionTimestampMs(value: string, label: string): number { @@ -136,65 +131,68 @@ export function isRuntimeContextCarrier(message: AgentMessage): boolean { /** Convert harness transcript messages into the LLM-facing message sequence. */ export function convertToLlm(messages: AgentMessage[]): Message[] { - return messages - .map((m): Message | undefined => { - const message = m as HarnessMessage; - switch (message.role) { - case "bashExecution": - if (message.excludeFromContext) { - return undefined; - } - return { - role: "user", - content: [{ type: "text", text: bashExecutionToText(message) }], - timestamp: message.timestamp, - }; - case "custom": { - if (message.excludeFromContext) { - return undefined; - } - const content = - typeof message.content === "string" - ? [{ type: "text" as const, text: message.content }] - : message.content; - // Preserve carrier identity so provider-owned replay and cache policy - // can distinguish transient context from append-only context. - return { - role: "user", - content, - timestamp: message.timestamp, - ...(isRuntimeContextCarrier(message) ? { runtimeContextCarrier: true } : {}), - }; + const llmMessages: Message[] = []; + // Preserve map's hole skipping and captured length without its intermediate array. + messages.forEach((message) => { + switch (message.role) { + case "bashExecution": + if (message.excludeFromContext) { + return; } - case "branchSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: BRANCH_SUMMARY_PREFIX + message.summary + BRANCH_SUMMARY_SUFFIX, - }, - ], - timestamp: message.timestamp, - }; - case "compactionSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: COMPACTION_SUMMARY_PREFIX + message.summary + COMPACTION_SUMMARY_SUFFIX, - }, - ], - timestamp: normalizeCompactionSummaryTimestamp(message.timestamp), - }; - case "user": - case "assistant": - case "toolResult": - return message; - default: - return undefined; + llmMessages.push({ + role: "user", + content: [{ type: "text", text: bashExecutionToText(message) }], + timestamp: message.timestamp, + }); + break; + case "custom": { + if (message.excludeFromContext) { + return; + } + const content = + typeof message.content === "string" + ? [{ type: "text" as const, text: message.content }] + : message.content; + // Preserve carrier identity so provider-owned replay and cache policy + // can distinguish transient context from append-only context. + llmMessages.push({ + role: "user", + content, + timestamp: message.timestamp, + ...(isRuntimeContextCarrier(message) ? { runtimeContextCarrier: true } : {}), + }); + break; } - }) - .filter((m): m is Message => m !== undefined); + case "branchSummary": + llmMessages.push({ + role: "user", + content: [ + { + type: "text" as const, + text: BRANCH_SUMMARY_PREFIX + message.summary + BRANCH_SUMMARY_SUFFIX, + }, + ], + timestamp: message.timestamp, + }); + break; + case "compactionSummary": + llmMessages.push({ + role: "user", + content: [ + { + type: "text" as const, + text: COMPACTION_SUMMARY_PREFIX + message.summary + COMPACTION_SUMMARY_SUFFIX, + }, + ], + timestamp: normalizeCompactionSummaryTimestamp(message.timestamp), + }); + break; + case "user": + case "assistant": + case "toolResult": + llmMessages.push(message); + break; + } + }); + return llmMessages; }