File size: 9,255 Bytes
eb3f11e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCliDispatchTranscriptRecorder } from "./cli-backend-dispatch-transcript.js";

const appendTranscriptMessage = vi.hoisted(() => vi.fn());

vi.mock("../../config/sessions/session-accessor.js", () => ({
  appendTranscriptMessage,
}));

type AppendedRecord = {
  scope: Record<string, unknown>;
  message: Record<string, unknown>;
};

function appendedRecords(): AppendedRecord[] {
  return appendTranscriptMessage.mock.calls.map((call) => ({
    scope: call[0] as Record<string, unknown>,
    message: (call[1] as { message: Record<string, unknown> }).message,
  }));
}

function recorderParams() {
  return {
    sessionId: "recall-session",
    sessionKey: "agent:main:recall",
    agentId: "main",
    sessionFile: "sqlite://agents/main/recall-session",
    runId: "run-transcript-test",
    prompt: "recall prompt",
    provider: "claude-cli",
    model: "claude-opus-4-8",
    expectedLifecycleRevision: "revision-a",
    expectedWriterRunId: "run-transcript-test",
    senderIsOwner: true,
  };
}

beforeEach(() => {
  appendTranscriptMessage.mockReset();
  appendTranscriptMessage.mockResolvedValue({ appended: true, message: {}, messageId: "m" });
});

describe("createCliDispatchTranscriptRecorder", () => {
  it("appends the user turn to the run's session identity", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    await recorder.finalize();

    const records = appendedRecords();
    expect(records[0]?.scope).toMatchObject({
      sessionId: "recall-session",
      sessionKey: "agent:main:recall",
      agentId: "main",
      sessionFile: "sqlite://agents/main/recall-session",
      expectedLifecycleRevision: "revision-a",
      expectedWriterRunId: "run-transcript-test",
    });
    expect(records[0]?.message).toMatchObject({
      role: "user",
      content: [{ type: "text", text: "recall prompt" }],
      __openclaw: { senderIsOwner: true },
    });
  });

  it("mirrors tool calls and results in the shapes the recall parsers accept", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteToolEvent({
      phase: "start",
      toolName: "memory_search",
      toolCallId: "call-1",
      args: { query: "wings" },
    });
    recorder.noteToolEvent({
      phase: "result",
      toolName: "memory_search",
      toolCallId: "call-1",
      result: {
        content: [{ type: "text", text: '{"results":[{"id":"m1"}]}' }],
        details: { results: [{ id: "m1" }], debug: { backend: "builtin", hits: 1 } },
      },
      isError: false,
    });
    await recorder.finalize("Lemon pepper.");

    const messages = appendedRecords().map((record) => record.message);
    expect(messages[1]).toMatchObject({
      role: "assistant",
      content: [
        { type: "toolCall", id: "call-1", name: "memory_search", arguments: { query: "wings" } },
      ],
      stopReason: "toolUse",
    });
    // The toolResult shape is what active-memory's transcript readers parse:
    // role/toolName gate the record; details/content decide usable-vs-unavailable.
    expect(messages[2]).toMatchObject({
      role: "toolResult",
      toolCallId: "call-1",
      toolName: "memory_search",
      content: [{ type: "text", text: '{"results":[{"id":"m1"}]}' }],
      details: { results: [{ id: "m1" }], debug: { backend: "builtin", hits: 1 } },
      isError: false,
    });
    expect(messages[3]).toMatchObject({
      role: "assistant",
      content: [{ type: "text", text: "Lemon pepper." }],
      stopReason: "stop",
    });
  });

  it("persists network-result taint on the result and subsequent assistant", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteToolEvent({
      phase: "result",
      toolName: "fake_web_tool",
      result: { content: [{ type: "text", text: "untrusted page" }] },
      isError: false,
      resultContentSource: "network",
    });
    await recorder.finalize("summary");

    const messages = appendedRecords().map((record) => record.message);
    expect(messages[1]).toMatchObject({
      role: "toolResult",
      __openclaw: { resultContentSource: "network" },
    });
    expect(messages[2]).toMatchObject({
      role: "assistant",
      __openclaw: { turnTainted: true },
    });
  });

  it("keeps bare-array tool_result content as claude stream-json echoes it", async () => {
    // Live claude -p runs deliver MCP tool results as `block.content` — a bare
    // content-block array with no {content} wrapper. Dropping it made every
    // successful recall classify as no_relevant_memory.
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteToolEvent({
      phase: "start",
      toolName: "memory_search",
      toolCallId: "call-1",
      args: { query: "copperfin" },
    });
    recorder.noteToolEvent({
      phase: "result",
      toolName: "memory_search",
      toolCallId: "call-1",
      result: [{ type: "text", text: '{"results":[{"path":"MEMORY.md"}]}' }],
      isError: false,
    });
    await recorder.finalize("Port 4173.");

    const messages = appendedRecords().map((record) => record.message);
    expect(messages[2]).toMatchObject({
      role: "toolResult",
      toolCallId: "call-1",
      toolName: "memory_search",
      content: [{ type: "text", text: '{"results":[{"path":"MEMORY.md"}]}' }],
      isError: false,
    });
  });

  it("appends tool records incrementally for the live terminal-search watcher", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteToolEvent({
      phase: "result",
      toolName: "memory_search",
      result: { content: [], details: { status: "unavailable", error: "backend offline" } },
      isError: true,
    });
    // No finalize yet: the record must be written mid-run.
    await vi.waitFor(() => {
      expect(appendedRecords().some((record) => record.message.role === "toolResult")).toBe(true);
    });
    const toolResult = appendedRecords().find(
      (record) => record.message.role === "toolResult",
    )?.message;
    expect(toolResult).toMatchObject({
      toolName: "memory_search",
      details: { status: "unavailable", error: "backend offline" },
      isError: true,
    });
    await recorder.finalize();
  });

  it("flushes the last streamed assistant snapshot when no final text exists", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteAssistantText("partial an");
    recorder.noteAssistantText("partial answer before timeout");
    await recorder.finalize(undefined);

    const assistant = appendedRecords().find(
      (record) => record.message.role === "assistant",
    )?.message;
    expect(assistant).toMatchObject({
      content: [{ type: "text", text: "partial answer before timeout" }],
    });
  });

  it("flushes the latest snapshot on abort and does not duplicate it at finalize", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteAssistantText("partial before kill");
    recorder.flushAssistantSnapshot();
    await vi.waitFor(() => {
      expect(
        appendedRecords().filter((record) => record.message.role === "assistant"),
      ).toHaveLength(1);
    });
    // The killed child settles later; finalize with the same text must not
    // append a duplicate record.
    await recorder.finalize(undefined);
    const assistants = appendedRecords().filter((record) => record.message.role === "assistant");
    expect(assistants).toHaveLength(1);
    expect(assistants[0]?.message).toMatchObject({
      content: [{ type: "text", text: "partial before kill" }],
      stopReason: "aborted",
    });
    expect(assistants[0]?.scope).toMatchObject({
      expectedLifecycleRevision: "revision-a",
      expectedWriterRunId: "run-transcript-test",
    });
  });

  it("writes a newer final text after an abort flush", async () => {
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteAssistantText("partial");
    recorder.flushAssistantSnapshot();
    await recorder.finalize("full final answer");
    const assistants = appendedRecords().filter((record) => record.message.role === "assistant");
    expect(assistants).toHaveLength(2);
    expect(assistants[1]?.message).toMatchObject({
      content: [{ type: "text", text: "full final answer" }],
      stopReason: "stop",
    });
  });

  it("survives append failures without failing the run or later appends", async () => {
    appendTranscriptMessage.mockRejectedValueOnce(new Error("store unavailable"));
    const recorder = createCliDispatchTranscriptRecorder(recorderParams());
    recorder.noteToolEvent({ phase: "result", toolName: "memory_search", isError: false });
    await expect(recorder.finalize("text")).resolves.toBeUndefined();
    // The user-turn append failed; the tool and assistant records still land.
    expect(appendedRecords().some((record) => record.message.role === "toolResult")).toBe(true);
    expect(appendedRecords().some((record) => record.message.role === "assistant")).toBe(true);
  });
});