diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index 3dfacf56f064..2eb895aa8fbe 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -5,11 +5,19 @@ import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-h import * as sessionUtils from "./session-utils.js"; type HistorySnapshot = ReturnType; +type RawStateOptions = Omit< + Parameters[0], + "target" | "rawMessages" +>; + +function textContent(text: string) { + return [{ type: "text" as const, text }]; +} function assistantTextMessage(text: string, seq: number) { return { role: "assistant" as const, - content: [{ type: "text" as const, text }], + content: textContent(text), __openclaw: { seq }, }; } @@ -17,42 +25,72 @@ function assistantTextMessage(text: string, seq: number) { function userTextMessage(text: string, seq: number) { return { role: "user" as const, - content: [{ type: "text" as const, text }], + content: textContent(text), __openclaw: { seq }, }; } -function newStateWithUserText(text: string): SessionHistorySseState { +function newState(rawMessages: Array>, options: RawStateOptions = {}) { return SessionHistorySseState.fromRawSnapshot({ target: { sessionId: "sess-main" }, - rawMessages: [userTextMessage(text, 1)], + rawMessages, + ...options, }); } +function newStateWithUserText(text: string): SessionHistorySseState { + return newState([userTextMessage(text, 1)]); +} + function expectOnlyAssistantText(snapshot: HistorySnapshot, text: string, seq: number): void { expect(snapshot.history.messages).toEqual([assistantTextMessage(text, seq)]); } +function messageToolCall(id: string, message: string, args: Record = {}) { + return { + type: "toolCall" as const, + id, + name: "message", + arguments: { + action: "send", + message, + ...args, + }, + }; +} + +function messageToolResult( + toolCallId: string, + messageId: string, + seq?: number, + content: Record = {}, +) { + return { + role: "toolResult" as const, + toolName: "message", + toolCallId, + content: { ok: true, messageId, ...content }, + ...(seq === undefined ? {} : { __openclaw: { seq } }), + }; +} + +function appendAssistantText(state: SessionHistorySseState, text: string, messageSeq?: number) { + return state.appendInlineMessage({ + message: { + role: "assistant", + content: textContent(text), + }, + ...(messageSeq === undefined ? {} : { messageSeq }), + }); +} + describe("SessionHistorySseState", () => { test("uses the initial raw snapshot for both first history and seq seeding", () => { - const readSpy = vi.spyOn(sessionUtils, "readSessionMessagesAsync").mockResolvedValue([ - { - role: "assistant", - content: [{ type: "text", text: "stale disk message" }], - __openclaw: { seq: 1 }, - }, - ]); + const readSpy = vi + .spyOn(sessionUtils, "readSessionMessagesAsync") + .mockResolvedValue([assistantTextMessage("stale disk message", 1)]); try { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "fresh snapshot message" }], - __openclaw: { seq: 2 }, - }, - ], - }); + const state = newState([assistantTextMessage("fresh snapshot message", 2)]); expect(state.snapshot().messages).toHaveLength(1); expect( @@ -97,24 +135,9 @@ describe("SessionHistorySseState", () => { }); test("uses carried sequence for inline SSE appends", () => { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "initial" }], - __openclaw: { seq: 2 }, - }, - ], - }); + const state = newState([assistantTextMessage("initial", 2)]); - const appended = state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "carried" }], - }, - messageSeq: 9, - }); + const appended = appendAssistantText(state, "carried", 9); expect(appended?.messageSeq).toBe(9); expect(state.snapshot().messages.at(-1)?.["__openclaw"]?.seq).toBe(9); @@ -128,16 +151,9 @@ describe("SessionHistorySseState", () => { message: { role: "assistant", content: [ - { - type: "toolCall", - id: "call-message-channel-hint", - name: "message", - arguments: { - action: "send", - channel: "telegram", - message: "Still the current chat.", - }, - }, + messageToolCall("call-message-channel-hint", "Still the current chat.", { + channel: "telegram", + }), ], }, messageSeq: 2, @@ -145,23 +161,14 @@ describe("SessionHistorySseState", () => { ).toBe(2); expect( state.appendInlineMessage({ - message: { - role: "toolResult", - toolName: "message", - toolCallId: "call-message-channel-hint", - content: { ok: true, messageId: "24270", chatId: "current-run" }, - }, + message: messageToolResult("call-message-channel-hint", "24270", undefined, { + chatId: "current-run", + }), messageSeq: 3, })?.messageSeq, ).toBe(3); - const appended = state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - }, - messageSeq: 4, - }); + const appended = appendAssistantText(state, "NO_REPLY", 4); expect(appended?.messageSeq).toBe(4); expect( @@ -183,38 +190,14 @@ describe("SessionHistorySseState", () => { test("keeps cursors when a paginated history page starts with a message-tool mirror", () => { const snapshot = buildSessionHistorySnapshot({ rawMessages: [ - { - role: "user", - content: [{ type: "text", text: "reply here" }], - __openclaw: { seq: 1 }, - }, + userTextMessage("reply here", 1), { role: "assistant", - content: [ - { - type: "toolCall", - id: "call-message-cursor", - name: "message", - arguments: { - action: "send", - message: "Cursor-visible reply.", - }, - }, - ], + content: [messageToolCall("call-message-cursor", "Cursor-visible reply.")], __openclaw: { seq: 2 }, }, - { - role: "toolResult", - toolName: "message", - toolCallId: "call-message-cursor", - content: { ok: true, messageId: "cursor" }, - __openclaw: { seq: 3 }, - }, - { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - __openclaw: { seq: 4 }, - }, + messageToolResult("call-message-cursor", "cursor", 3), + assistantTextMessage("NO_REPLY", 4), ], limit: 1, }); @@ -236,69 +219,28 @@ describe("SessionHistorySseState", () => { }); test("requests refresh when silent control reply completes multiple message-tool mirrors", () => { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "user", - content: [{ type: "text", text: "send both here" }], - __openclaw: { seq: 1 }, - }, - ], - }); + const state = newState([userTextMessage("send both here", 1)]); state.appendInlineMessage({ message: { role: "assistant", content: [ - { - type: "toolCall", - id: "call-message-first", - name: "message", - arguments: { - action: "send", - message: "First visible reply.", - }, - }, - { - type: "toolCall", - id: "call-message-second", - name: "message", - arguments: { - action: "send", - message: "Second visible reply.", - }, - }, + messageToolCall("call-message-first", "First visible reply."), + messageToolCall("call-message-second", "Second visible reply."), ], }, messageSeq: 2, }); state.appendInlineMessage({ - message: { - role: "toolResult", - toolName: "message", - toolCallId: "call-message-first", - content: { ok: true, messageId: "first" }, - }, + message: messageToolResult("call-message-first", "first"), messageSeq: 3, }); state.appendInlineMessage({ - message: { - role: "toolResult", - toolName: "message", - toolCallId: "call-message-second", - content: { ok: true, messageId: "second" }, - }, + message: messageToolResult("call-message-second", "second"), messageSeq: 4, }); - const appended = state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - }, - messageSeq: 5, - }); + const appended = appendAssistantText(state, "NO_REPLY", 5); expect(appended).toEqual({ shouldRefresh: true }); expect( @@ -314,13 +256,7 @@ describe("SessionHistorySseState", () => { test("does not emit a no-op hidden inline control reply", () => { const state = newStateWithUserText("reply here"); - const appended = state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - }, - messageSeq: 2, - }); + const appended = appendAssistantText(state, "NO_REPLY", 2); expect(appended).toBeNull(); expect(state.snapshot().messages).toHaveLength(1); @@ -329,16 +265,7 @@ describe("SessionHistorySseState", () => { test("requests refresh when inline TTS supplement merges into an existing assistant message", () => { const visibleText = "Here is the answer."; const textSha256 = createHash("sha256").update(visibleText).digest("hex"); - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: visibleText }], - __openclaw: { seq: 2 }, - }, - ], - }); + const state = newState([assistantTextMessage(visibleText, 2)]); const appended = state.appendInlineMessage({ message: { @@ -365,7 +292,7 @@ describe("SessionHistorySseState", () => { { role: "assistant", content: [ - { type: "text", text: visibleText }, + textContent(visibleText)[0], { type: "attachment", attachment: { @@ -382,24 +309,9 @@ describe("SessionHistorySseState", () => { }); test("requests refresh for non-monotonic carried inline sequence", () => { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "current" }], - __openclaw: { seq: 5 }, - }, - ], - }); + const state = newState([assistantTextMessage("current", 5)]); - const appended = state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "rewound branch" }], - }, - messageSeq: 3, - }); + const appended = appendAssistantText(state, "rewound branch", 3); expect(appended).toEqual({ shouldRefresh: true }); expect(state.snapshot().messages).toHaveLength(1); @@ -408,13 +320,7 @@ describe("SessionHistorySseState", () => { test("marks bounded tail snapshots as having older history", () => { const snapshot = buildSessionHistorySnapshot({ - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "tail" }], - __openclaw: { seq: 99 }, - }, - ], + rawMessages: [assistantTextMessage("tail", 99)], limit: 1, rawTranscriptSeq: 99, totalRawMessages: 99, @@ -430,25 +336,11 @@ describe("SessionHistorySseState", () => { const tailReadSpy = vi .spyOn(sessionUtils, "readRecentSessionMessagesWithStatsAsync") .mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "tail two" }], - __openclaw: { seq: 8 }, - }, - ], + messages: [assistantTextMessage("tail two", 8)], totalMessages: 8, }); try { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "tail one" }], - __openclaw: { seq: 7 }, - }, - ], + const state = newState([assistantTextMessage("tail one", 7)], { rawTranscriptSeq: 7, totalRawMessages: 7, limit: 1, @@ -566,21 +458,11 @@ describe("SessionHistorySseState", () => { }, __openclaw: { seq: 1 }, }, - { - role: "assistant", - content: [{ type: "text", text: "clean child result" }], - __openclaw: { seq: 2 }, - }, + assistantTextMessage("clean child result", 2), ], }); - expect(snapshot.history.messages).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "clean child result" }], - __openclaw: { seq: 2 }, - }, - ]); + expectOnlyAssistantText(snapshot, "clean child result", 2); }); test("hides heartbeat prompt and ok acknowledgements from visible history", () => { @@ -591,45 +473,22 @@ describe("SessionHistorySseState", () => { content: `${HEARTBEAT_PROMPT}\nWhen reading HEARTBEAT.md, use workspace file /tmp/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.`, __openclaw: { seq: 1 }, }, - { - role: "assistant", - content: [{ type: "text", text: "HEARTBEAT_OK" }], - __openclaw: { seq: 2 }, - }, + assistantTextMessage("HEARTBEAT_OK", 2), { role: "user", content: HEARTBEAT_PROMPT, __openclaw: { seq: 3 }, }, - { - role: "assistant", - content: [{ type: "text", text: "Disk usage crossed 95 percent." }], - __openclaw: { seq: 4 }, - }, + assistantTextMessage("Disk usage crossed 95 percent.", 4), ], }); - expect(snapshot.history.messages).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "Disk usage crossed 95 percent." }], - __openclaw: { seq: 4 }, - }, - ]); + expectOnlyAssistantText(snapshot, "Disk usage crossed 95 percent.", 4); expect(snapshot.rawTranscriptSeq).toBe(4); }); test("does not append heartbeat or internal-only SSE messages", () => { - const state = SessionHistorySseState.fromRawSnapshot({ - target: { sessionId: "sess-main" }, - rawMessages: [ - { - role: "assistant", - content: [{ type: "text", text: "already visible" }], - __openclaw: { seq: 1 }, - }, - ], - }); + const state = newState([assistantTextMessage("already visible", 1)]); expect( state.appendInlineMessage({ @@ -639,14 +498,7 @@ describe("SessionHistorySseState", () => { }, }), ).toBeNull(); - expect( - state.appendInlineMessage({ - message: { - role: "assistant", - content: [{ type: "text", text: "HEARTBEAT_OK" }], - }, - }), - ).toBeNull(); + expect(appendAssistantText(state, "HEARTBEAT_OK")).toBeNull(); expect( state.appendInlineMessage({ message: {