






















@@ -1,7 +1,7 @@
11import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
4-import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
4+import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
55import { afterEach, describe, expect, it, vi } from "vitest";
66import {
77__testing as replyRunTesting,
@@ -18,6 +18,7 @@ import {
1818} from "./cli-runner.test-support.js";
1919import { executePreparedCliRun } from "./cli-runner/execute.js";
2020import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js";
21+import { MAX_CLI_SESSION_HISTORY_MESSAGES } from "./cli-runner/session-history.js";
2122import type { PreparedCliRunContext } from "./cli-runner/types.js";
22232324vi.mock("../plugins/hook-runner-global.js", async () => {
@@ -34,7 +35,9 @@ const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
34353536function createSessionFile(params?: { history?: Array<{ role: "user"; content: string }> }) {
3637const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
37-const sessionFile = path.join(dir, "session.jsonl");
38+vi.stubEnv("OPENCLAW_STATE_DIR", dir);
39+const sessionFile = path.join(dir, "agents", "main", "sessions", "s1.jsonl");
40+fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
3841fs.writeFileSync(
3942sessionFile,
4043`${JSON.stringify({
@@ -46,13 +49,22 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
4649 })}\n`,
4750"utf-8",
4851);
49-const sessionManager = SessionManager.open(sessionFile);
50-for (const entry of params?.history ?? []) {
51-sessionManager.appendMessage({
52-role: entry.role,
53-content: entry.content,
54-timestamp: Date.now(),
55-});
52+for (const [index, entry] of (params?.history ?? []).entries()) {
53+fs.appendFileSync(
54+sessionFile,
55+`${JSON.stringify({
56+ type: "message",
57+ id: `msg-${index}`,
58+ parentId: index > 0 ? `msg-${index - 1}` : null,
59+ timestamp: new Date(index + 1).toISOString(),
60+ message: {
61+ role: entry.role,
62+ content: entry.content,
63+ timestamp: index + 1,
64+ },
65+ })}\n`,
66+"utf-8",
67+);
5668}
5769return { dir, sessionFile };
5870}
@@ -110,6 +122,7 @@ describe("runCliAgent reliability", () => {
110122afterEach(() => {
111123replyRunTesting.resetReplyRunRegistry();
112124mockGetGlobalHookRunner.mockReset();
125+vi.unstubAllEnvs();
113126});
114127115128it("fails with timeout when no-output watchdog trips", async () => {
@@ -193,6 +206,13 @@ describe("runCliAgent reliability", () => {
193206});
194207195208it("rethrows the retry failure when session-expired recovery retry also fails", async () => {
209+const hookRunner = {
210+hasHooks: vi.fn((hookName: string) => ["llm_input", "agent_end"].includes(hookName)),
211+runLlmInput: vi.fn(async () => undefined),
212+runLlmOutput: vi.fn(async () => undefined),
213+runAgentEnd: vi.fn(async () => undefined),
214+};
215+mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
196216supervisorSpawnMock.mockClear();
197217supervisorSpawnMock.mockResolvedValueOnce(
198218createManagedRun({
@@ -218,18 +238,50 @@ describe("runCliAgent reliability", () => {
218238noOutputTimedOut: false,
219239}),
220240);
241+const { dir, sessionFile } = createSessionFile({
242+history: [{ role: "user", content: "earlier context" }],
243+});
221244222-await expect(
223-runPreparedCliAgent(
224-buildPreparedContext({
225-sessionKey: "agent:main:subagent:retry",
226-runId: "run-retry-failure",
227-cliSessionId: "thread-123",
245+try {
246+await expect(
247+runPreparedCliAgent({
248+ ...buildPreparedContext({
249+sessionKey: "agent:main:subagent:retry",
250+runId: "run-retry-failure",
251+cliSessionId: "thread-123",
252+}),
253+params: {
254+ ...buildPreparedContext({
255+sessionKey: "agent:main:subagent:retry",
256+runId: "run-retry-failure",
257+cliSessionId: "thread-123",
258+}).params,
259+agentId: "main",
260+ sessionFile,
261+workspaceDir: dir,
262+},
228263}),
229-),
230-).rejects.toThrow("rate limit exceeded");
264+).rejects.toThrow("rate limit exceeded");
231265232-expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
266+expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
267+await vi.waitFor(() => {
268+expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1);
269+expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
270+});
271+expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
272+expect.objectContaining({
273+success: false,
274+error: "rate limit exceeded",
275+messages: [
276+{ role: "user", content: "earlier context", timestamp: expect.any(Number) },
277+{ role: "user", content: "hi", timestamp: expect.any(Number) },
278+],
279+}),
280+expect.any(Object),
281+);
282+} finally {
283+fs.rmSync(dir, { recursive: true, force: true });
284+}
233285});
234286235287it("returns the assembled CLI prompt in meta for raw trace consumers", async () => {
@@ -499,6 +551,89 @@ describe("runCliAgent reliability", () => {
499551expect.any(Object),
500552);
501553});
554+555+it("does not emit duplicate llm_input when session-expired recovery succeeds", async () => {
556+const hookRunner = {
557+hasHooks: vi.fn((hookName: string) =>
558+["llm_input", "llm_output", "agent_end"].includes(hookName),
559+),
560+runLlmInput: vi.fn(async () => undefined),
561+runLlmOutput: vi.fn(async () => undefined),
562+runAgentEnd: vi.fn(async () => undefined),
563+};
564+mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
565+const { dir, sessionFile } = createSessionFile({
566+history: Array.from({ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 5 }, (_, index) => ({
567+role: "user" as const,
568+content: `history-${index}`,
569+})),
570+});
571+572+supervisorSpawnMock.mockResolvedValueOnce(
573+createManagedRun({
574+reason: "exit",
575+exitCode: 1,
576+exitSignal: null,
577+durationMs: 50,
578+stdout: "",
579+stderr: "session expired",
580+timedOut: false,
581+noOutputTimedOut: false,
582+}),
583+);
584+supervisorSpawnMock.mockResolvedValueOnce(
585+createManagedRun({
586+reason: "exit",
587+exitCode: 0,
588+exitSignal: null,
589+durationMs: 50,
590+stdout: "recovered output",
591+stderr: "",
592+timedOut: false,
593+noOutputTimedOut: false,
594+}),
595+);
596+597+try {
598+await expect(
599+runPreparedCliAgent({
600+ ...buildPreparedContext({
601+sessionKey: "agent:main:main",
602+runId: "run-retry-success",
603+cliSessionId: "thread-123",
604+}),
605+params: {
606+ ...buildPreparedContext({
607+sessionKey: "agent:main:main",
608+runId: "run-retry-success",
609+cliSessionId: "thread-123",
610+}).params,
611+agentId: "main",
612+ sessionFile,
613+workspaceDir: dir,
614+},
615+}),
616+).resolves.toMatchObject({
617+payloads: [{ text: "recovered output" }],
618+});
619+620+await vi.waitFor(() => {
621+expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1);
622+expect(hookRunner.runLlmOutput).toHaveBeenCalledTimes(1);
623+expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
624+});
625+const llmInputCalls = hookRunner.runLlmInput.mock.calls as unknown as Array<Array<unknown>>;
626+const llmInputEvent = llmInputCalls[0]?.[0] as { historyMessages: unknown[] } | undefined;
627+expect(llmInputEvent).toBeDefined();
628+expect(llmInputEvent?.historyMessages).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES);
629+expect(llmInputEvent?.historyMessages[0]).toMatchObject({
630+role: "user",
631+content: `history-5`,
632+});
633+} finally {
634+fs.rmSync(dir, { recursive: true, force: true });
635+}
636+});
502637});
503638504639describe("resolveCliNoOutputTimeoutMs", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。