




























@@ -0,0 +1,197 @@
1+import fs from "node:fs/promises";
2+import os from "node:os";
3+import path from "node:path";
4+import {
5+resetAgentEventsForTest,
6+type EmbeddedRunAttemptParams,
7+} from "openclaw/plugin-sdk/agent-harness-runtime";
8+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+import type { CodexAppServerClientFactory } from "./client-factory.js";
10+import type { CodexServerNotification } from "./protocol.js";
11+import { runCodexAppServerAttempt } from "./run-attempt.js";
12+import { createCodexTestModel } from "./test-support.js";
13+14+let tempDir: string;
15+16+function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
17+return {
18+prompt: "hello",
19+sessionId: "session-1",
20+sessionKey: "agent:main:session-1",
21+ sessionFile,
22+ workspaceDir,
23+runId: "run-1",
24+provider: "codex",
25+modelId: "gpt-5.4-codex",
26+model: createCodexTestModel("codex"),
27+thinkLevel: "medium",
28+disableTools: true,
29+timeoutMs: 5_000,
30+authStorage: {} as never,
31+authProfileStore: { version: 1, profiles: {} },
32+modelRegistry: {} as never,
33+} as EmbeddedRunAttemptParams;
34+}
35+36+function threadStartResult(threadId = "thread-1") {
37+return {
38+thread: {
39+id: threadId,
40+sessionId: "session-1",
41+forkedFromId: null,
42+preview: "",
43+ephemeral: false,
44+modelProvider: "openai",
45+createdAt: 1,
46+updatedAt: 1,
47+status: { type: "idle" },
48+path: null,
49+cwd: tempDir || "/tmp/openclaw-codex-test",
50+cliVersion: "0.125.0",
51+source: "unknown",
52+agentNickname: null,
53+agentRole: null,
54+gitInfo: null,
55+name: null,
56+turns: [],
57+},
58+model: "gpt-5.4-codex",
59+modelProvider: "openai",
60+serviceTier: null,
61+cwd: tempDir || "/tmp/openclaw-codex-test",
62+instructionSources: [],
63+approvalPolicy: "never",
64+approvalsReviewer: "user",
65+sandbox: { type: "dangerFullAccess" },
66+permissionProfile: null,
67+reasoningEffort: null,
68+};
69+}
70+71+function turnStartResult(turnId = "turn-1") {
72+return {
73+turn: {
74+id: turnId,
75+status: "inProgress",
76+items: [],
77+error: null,
78+startedAt: null,
79+completedAt: null,
80+durationMs: null,
81+},
82+};
83+}
84+85+describe("Codex app-server main thread cleanup", () => {
86+beforeEach(async () => {
87+resetAgentEventsForTest();
88+vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
89+vi.stubEnv("CODEX_API_KEY", "");
90+vi.stubEnv("OPENAI_API_KEY", "");
91+tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
92+});
93+94+afterEach(async () => {
95+resetAgentEventsForTest();
96+vi.restoreAllMocks();
97+vi.unstubAllEnvs();
98+await fs.rm(tempDir, { recursive: true, force: true });
99+});
100+101+it("unsubscribes the main Codex thread after a completed turn", async () => {
102+const sessionFile = path.join(tempDir, "session.jsonl");
103+const workspaceDir = path.join(tempDir, "workspace");
104+const requests: Array<{ method: string; params: unknown }> = [];
105+let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
106+const request = vi.fn(async (method: string, params?: unknown) => {
107+requests.push({ method, params });
108+if (method === "thread/start") {
109+return threadStartResult();
110+}
111+if (method === "turn/start") {
112+return turnStartResult();
113+}
114+return {};
115+});
116+117+const clientFactory: CodexAppServerClientFactory = async () => {
118+return {
119+ request,
120+addNotificationHandler: (handler: typeof notify) => {
121+notify = handler;
122+return () => undefined;
123+},
124+addRequestHandler: () => () => undefined,
125+} as never;
126+};
127+128+const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
129+ clientFactory,
130+});
131+await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/start"), {
132+interval: 1,
133+timeout: 5_000,
134+});
135+await notify({
136+method: "turn/completed",
137+params: {
138+threadId: "thread-1",
139+turnId: "turn-1",
140+turn: { id: "turn-1", status: "completed" },
141+},
142+});
143+144+const result = await run;
145+expect(result.aborted).toBe(false);
146+expect(request).toHaveBeenCalledWith(
147+"thread/unsubscribe",
148+{ threadId: "thread-1" },
149+{ timeoutMs: 5_000 },
150+);
151+expect(requests.map((entry) => entry.method)).toEqual([
152+"thread/start",
153+"turn/start",
154+"thread/unsubscribe",
155+]);
156+});
157+158+it("unsubscribes the main Codex thread when turn start fails", async () => {
159+const sessionFile = path.join(tempDir, "session.jsonl");
160+const workspaceDir = path.join(tempDir, "workspace");
161+const requests: Array<{ method: string; params: unknown }> = [];
162+const request = vi.fn(async (method: string, params?: unknown) => {
163+requests.push({ method, params });
164+if (method === "thread/start") {
165+return threadStartResult();
166+}
167+if (method === "turn/start") {
168+throw new Error("turn start exploded");
169+}
170+return {};
171+});
172+173+const clientFactory: CodexAppServerClientFactory = async () => {
174+return {
175+ request,
176+addNotificationHandler: () => () => undefined,
177+addRequestHandler: () => () => undefined,
178+} as never;
179+};
180+181+await expect(
182+runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
183+ clientFactory,
184+}),
185+).rejects.toThrow("turn start exploded");
186+expect(requests.map((entry) => entry.method)).toEqual([
187+"thread/start",
188+"turn/start",
189+"thread/unsubscribe",
190+]);
191+expect(request).toHaveBeenCalledWith(
192+"thread/unsubscribe",
193+{ threadId: "thread-1" },
194+{ timeoutMs: 5_000 },
195+);
196+});
197+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。