mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(inference): prevent lost cancellation, stuck compaction, and runaway tools (#118146)
* fix(inference): preserve cancellations, compaction, and tool-loop safety * fix(inference): satisfy Codex and mock-provider type checks * fix(codex): secure tool-free recovery and restricted turns * fix(codex): satisfy host-prompt provenance lint
This commit is contained in:
committed by
GitHub
parent
8699f10873
commit
30c79e4db1
@@ -1,13 +1,37 @@
|
||||
// Codex tests cover attempt client cleanup plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
closeCodexStartupClientBestEffort,
|
||||
interruptCodexTurnAndWaitBestEffort,
|
||||
retireUnsafeCodexTurnClientBestEffort,
|
||||
retireCodexAppServerClientAfterTimedOutTurn,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
describe("Codex app-server attempt client cleanup", () => {
|
||||
it("keeps strict startup retirement failures visible to lifecycle owners", async () => {
|
||||
const closeAndWait = vi.fn(async () => {
|
||||
throw new Error("strict client retirement failed");
|
||||
});
|
||||
|
||||
await expect(closeCodexStartupClientBestEffort({ closeAndWait } as never)).rejects.toThrow(
|
||||
"strict client retirement failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the primary failure when unsafe turn retirement rejects", async () => {
|
||||
const close = vi.fn();
|
||||
const closeAndWait = vi.fn(async () => {
|
||||
throw new Error("unsafe client retirement failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
retireUnsafeCodexTurnClientBestEffort({ close, closeAndWait } as never, "startup interrupt"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for the matching terminal after an interrupt is acknowledged", async () => {
|
||||
const harness = createClientHarness();
|
||||
const completion = interruptCodexTurnAndWaitBestEffort(harness.client, {
|
||||
|
||||
@@ -76,6 +76,29 @@ export async function closeCodexStartupClientBestEffort(
|
||||
}
|
||||
}
|
||||
|
||||
/** Retires an unsafe turn client without replacing an already-authoritative failure. */
|
||||
export async function retireUnsafeCodexTurnClientBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server unsafe turn client retirement failed", {
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
try {
|
||||
client.close();
|
||||
} catch (closeError) {
|
||||
embeddedAgentLog.debug("codex app-server unsafe turn client close failed", {
|
||||
operation,
|
||||
error: closeError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a bounded turn interrupt and waits for Codex to confirm terminal abort handling. */
|
||||
export async function interruptCodexTurnAndWaitBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
|
||||
@@ -423,13 +423,18 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => {
|
||||
dynamicTools: [],
|
||||
ephemeral: true,
|
||||
config: {
|
||||
"agents.enabled": false,
|
||||
"features.hooks": false,
|
||||
"features.multi_agent": false,
|
||||
"features.multi_agent_v2": false,
|
||||
"skills.include_instructions": false,
|
||||
include_environment_context: false,
|
||||
mcp_servers: { inherited: { enabled: false } },
|
||||
},
|
||||
});
|
||||
const turnParams = fake.request.mock.calls.find(([method]) => method === "turn/start")?.[1];
|
||||
expect(turnParams).not.toHaveProperty("cwd");
|
||||
expect(turnParams).not.toHaveProperty("environments");
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/inject_items",
|
||||
{ threadId: "thread-finalizer", items: historyItems },
|
||||
|
||||
@@ -48,10 +48,11 @@ import {
|
||||
readCodexInheritedMcpServerNames,
|
||||
} from "./thread-requests.js";
|
||||
|
||||
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
|
||||
const CODEX_APP_SERVER_ARGS_ENV_KEY = "OPENCLAW_CODEX_APP_SERVER_ARGS";
|
||||
const CODEX_BOUNDED_THREAD_CONFIG: JsonObject = {
|
||||
"agents.enabled": false,
|
||||
"features.multi_agent": false,
|
||||
"features.multi_agent_v2": false,
|
||||
"features.apps": false,
|
||||
"features.plugins": false,
|
||||
"features.image_generation": false,
|
||||
@@ -282,12 +283,12 @@ async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
);
|
||||
try {
|
||||
const turn = assertCodexTurnStartResponse(
|
||||
// Inherit the empty thread environment; a cwd override recreates native tools.
|
||||
await client.request<unknown>(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: thread.thread.id,
|
||||
input: params.input,
|
||||
cwd: workspace.cwd,
|
||||
approvalPolicy: "on-request",
|
||||
model,
|
||||
effort: "low",
|
||||
@@ -375,6 +376,19 @@ function buildPrivateCodexAppServerStartOptions(
|
||||
start: ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"],
|
||||
codexHome: string,
|
||||
): ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"] {
|
||||
// Provider identity and model catalogs must survive isolation; hooks, MCP,
|
||||
// sandbox policy, and other process overrides must not cross that boundary.
|
||||
const providerArgs = start.args.flatMap((arg, index) => {
|
||||
const override =
|
||||
arg === "-c" || arg === "--config"
|
||||
? start.args[index + 1]
|
||||
: arg.startsWith("--config=")
|
||||
? arg.slice("--config=".length)
|
||||
: undefined;
|
||||
return override && /^\s*(?:openai_base_url|model_catalog_json)\s*=/u.test(override)
|
||||
? ["-c", override]
|
||||
: [];
|
||||
});
|
||||
const privateEnv = Object.fromEntries(
|
||||
Object.entries(start.env ?? {}).filter(
|
||||
([name]) => name.trim().toUpperCase() !== CODEX_APP_SERVER_ARGS_ENV_KEY,
|
||||
@@ -386,7 +400,7 @@ function buildPrivateCodexAppServerStartOptions(
|
||||
});
|
||||
return {
|
||||
...start,
|
||||
args: [...CODEX_PRIVATE_STDIO_ARGS],
|
||||
args: ["app-server", ...providerArgs, "--listen", "stdio://"],
|
||||
env: {
|
||||
...privateEnv,
|
||||
CODEX_HOME: codexHome,
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
type HarnessContextEngine as ContextEngine,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
consumeCodexAppServerLiveThread,
|
||||
ensureCodexAppServerClientRuntime,
|
||||
retainCodexAppServerLiveThread,
|
||||
} from "./client-runtime.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
|
||||
import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js";
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientFactory } from "./shared-client.js";
|
||||
import { CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
@@ -202,6 +208,88 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
expect(details.completed).toBe(true);
|
||||
});
|
||||
|
||||
it("resubscribes an evicted session before compacting without displacing its sibling", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
|
||||
await fake.client.request("thread/resume", { threadId: "thread-2", excludeTurns: true });
|
||||
await retainCodexAppServerLiveThread(
|
||||
fake.client,
|
||||
"thread-2",
|
||||
async (threadId) => {
|
||||
await fake.client.request("thread/unsubscribe", { threadId });
|
||||
},
|
||||
"config-thread-2",
|
||||
);
|
||||
fake.request.mockClear();
|
||||
|
||||
await expect(startCompaction(sessionFile)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
});
|
||||
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/compact/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/resume",
|
||||
{ threadId: "thread-1", excludeTurns: true },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(fake.client, "thread-2", "config-thread-2"),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an owned thread subscribed when a sibling finishes during compaction", async () => {
|
||||
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
const pending = startCompaction(sessionFile);
|
||||
await vi.waitFor(() => {
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
});
|
||||
|
||||
await fake.client.request("thread/resume", { threadId: "thread-2", excludeTurns: true });
|
||||
await retainCodexAppServerLiveThread(fake.client, "thread-2", undefined, "config-thread-2");
|
||||
fake.completeCompaction();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ ok: true, compacted: true });
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(fake.client, "thread-2", "config-thread-2"),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an incognito thread's separately owned live subscription", async () => {
|
||||
const fake = createFakeCodexClient({
|
||||
retainedThreadId: null,
|
||||
subscribedThreadIds: ["thread-1"],
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionKey = "agent:main:dashboard:incognito-compact";
|
||||
const sessionFile = await writeTestBinding({}, sessionKey);
|
||||
|
||||
await expect(
|
||||
maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, compacted: true });
|
||||
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual(["thread/compact/start"]);
|
||||
});
|
||||
|
||||
it("uses the exact prepared Platform key for native compaction", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const factory = vi.fn<CodexAppServerClientFactory>(async () => fake.client);
|
||||
@@ -289,7 +377,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
|
||||
it("uses the native supervision runtime and auth for supervised bindings", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const fake = createFakeCodexClient({ retainedThreadId: null });
|
||||
const factory = vi.fn(async () => fake.client);
|
||||
const sessionFile = await writeSupervisedTestBinding({
|
||||
authProfileId: "openai:binding-profile",
|
||||
@@ -319,6 +407,11 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
startOptions: expect.objectContaining({ homeScope: "user" }),
|
||||
}),
|
||||
);
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/compact/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when a supervised binding is no longer enabled", async () => {
|
||||
@@ -382,7 +475,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
|
||||
it("starts native app-server compaction for post-context-engine budget requests", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const fake = createFakeCodexClient({ retainedThreadId: null });
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
@@ -417,6 +510,11 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/compact/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
@@ -588,7 +686,10 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
let externalWriteStarted = false;
|
||||
let externalWriteFinished = false;
|
||||
const fake = createFakeCodexClient();
|
||||
fake.request.mockImplementation(async () => {
|
||||
fake.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
const response = await expectExternalMutationBlockedDuringNativeRequest({
|
||||
releaseExternalMutation: releaseExternalWrite,
|
||||
isExternalMutationStarted: () => externalWriteStarted,
|
||||
@@ -672,7 +773,10 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
let externalClearStarted = false;
|
||||
let externalClearFinished = false;
|
||||
const fake = createFakeCodexClient();
|
||||
fake.request.mockImplementation(async () => {
|
||||
fake.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
const response = await expectExternalMutationBlockedDuringNativeRequest({
|
||||
releaseExternalMutation: releaseExternalClear,
|
||||
isExternalMutationStarted: () => externalClearStarted,
|
||||
@@ -1293,7 +1397,9 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
fake.completeCompaction();
|
||||
await expect(first).resolves.toMatchObject({ ok: true, compacted: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(fake.request).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
fake.request.mock.calls.filter(([method]) => method === "thread/compact/start"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
fake.completeCompaction();
|
||||
@@ -1393,7 +1499,9 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
fake.completeCompaction();
|
||||
await expect(first).resolves.toMatchObject({ ok: true, compacted: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(fake.request).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
fake.request.mock.calls.filter(([method]) => method === "thread/compact/start"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
fake.completeCompaction();
|
||||
@@ -1487,7 +1595,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
|
||||
it("keeps the lifecycle fence when an unconfirmed stdio process does not stop", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const fake = createFakeCodexClient({ retainedThreadId: "thread-stuck-stdio" });
|
||||
fake.request.mockRejectedValueOnce(new Error("thread/compact/start timed out"));
|
||||
fake.closeAndWait.mockResolvedValueOnce(false);
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
@@ -1831,7 +1939,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
|
||||
it("forwards compaction to native Codex even when a context engine owns compaction", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const fake = createFakeCodexClient({ retainedThreadId: null });
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
const compact = vi.fn(async () => ({
|
||||
@@ -1872,6 +1980,11 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
);
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/compact/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
@@ -1928,6 +2041,8 @@ function createFakeCodexClient(
|
||||
autoCompleteCompaction?: boolean;
|
||||
interruptError?: Error;
|
||||
rejectInterrupt?: boolean;
|
||||
retainedThreadId?: string | null;
|
||||
subscribedThreadIds?: readonly string[];
|
||||
} = {},
|
||||
): {
|
||||
client: CodexAppServerClient;
|
||||
@@ -1939,7 +2054,16 @@ function createFakeCodexClient(
|
||||
} {
|
||||
const handlers = new Set<(notification: CodexServerNotification) => void>();
|
||||
const closeHandlers = new Set<() => void>();
|
||||
const retainedThreadId =
|
||||
options.retainedThreadId === undefined ? "thread-1" : options.retainedThreadId;
|
||||
const subscribedThreadIds = new Set(
|
||||
options.subscribedThreadIds ?? (retainedThreadId ? [retainedThreadId] : []),
|
||||
);
|
||||
const emit = (notification: CodexServerNotification): void => {
|
||||
const threadId = (notification.params as { threadId?: string } | undefined)?.threadId;
|
||||
if (threadId && !subscribedThreadIds.has(threadId)) {
|
||||
return;
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
handler(notification);
|
||||
}
|
||||
@@ -1978,6 +2102,46 @@ function createFakeCodexClient(
|
||||
};
|
||||
const request = vi.fn<CodexAppServerClient["request"]>(
|
||||
async (method: string, params?: unknown) => {
|
||||
const threadId = (params as { threadId?: string } | undefined)?.threadId;
|
||||
if (method === "thread/resume" && threadId) {
|
||||
subscribedThreadIds.add(threadId);
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.5-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: tempDir,
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe" && threadId) {
|
||||
subscribedThreadIds.delete(threadId);
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/interrupt" && options.interruptError) {
|
||||
throw options.interruptError;
|
||||
}
|
||||
@@ -1985,7 +2149,6 @@ function createFakeCodexClient(
|
||||
throw new Error("interrupt unavailable");
|
||||
}
|
||||
if (method === "thread/compact/start" && options.autoCompleteCompaction !== false) {
|
||||
const threadId = (params as { threadId?: unknown }).threadId;
|
||||
if (typeof threadId !== "string") {
|
||||
throw new Error("thread/compact/start requires threadId");
|
||||
}
|
||||
@@ -2039,17 +2202,29 @@ function createFakeCodexClient(
|
||||
return () => handlers.delete(handler);
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
request,
|
||||
close,
|
||||
closeAndWait,
|
||||
addNotificationHandler,
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
addCloseHandler: vi.fn((handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
}),
|
||||
} as unknown as CodexAppServerClient;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: tempDir });
|
||||
addNotificationHandler.mockClear();
|
||||
if (retainedThreadId) {
|
||||
void retainCodexAppServerLiveThread(
|
||||
client,
|
||||
retainedThreadId,
|
||||
undefined,
|
||||
`config-${retainedThreadId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
client: {
|
||||
request,
|
||||
close,
|
||||
closeAndWait,
|
||||
addNotificationHandler,
|
||||
addCloseHandler: vi.fn((handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
}),
|
||||
} as unknown as CodexAppServerClient,
|
||||
client,
|
||||
request,
|
||||
close,
|
||||
closeAndWait,
|
||||
|
||||
@@ -8,9 +8,19 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { isCodexAlreadyTerminalInterruptError } from "./attempt-client-cleanup.js";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { isIncognitoSessionKey } from "../incognito-session.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
closeCodexStartupClientBestEffort,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
isCodexAlreadyTerminalInterruptError,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { readCodexNotificationItem } from "./attempt-notifications.js";
|
||||
import { resolveCodexBindingAppServerConnection } from "./binding-connection.js";
|
||||
import { consumeCodexAppServerLiveThread } from "./client-runtime.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
readCodexNotificationThreadId,
|
||||
@@ -30,9 +40,10 @@ import {
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./shared-client.js";
|
||||
import { resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
const codexNativeCompactionQueues = new Map<string, Promise<void>>();
|
||||
const codexNativeCompactionQueue = new KeyedAsyncQueue();
|
||||
const CODEX_NATIVE_COMPACTION_INTERRUPT_GRACE_MS = 30_000;
|
||||
type CodexAppServerCompactOptions = {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
@@ -287,51 +298,30 @@ async function runExclusiveCodexNativeCompaction<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = codexNativeCompactionQueues.get(threadId) ?? Promise.resolve();
|
||||
let releaseCurrent!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseCurrent = resolve;
|
||||
});
|
||||
const queued = previous.then(
|
||||
() => current,
|
||||
() => current,
|
||||
);
|
||||
codexNativeCompactionQueues.set(threadId, queued);
|
||||
try {
|
||||
await waitForCodexNativeCompactionQueue(previous, signal);
|
||||
signal?.throwIfAborted();
|
||||
let started = false;
|
||||
const queued = codexNativeCompactionQueue.enqueue(threadId, async () => {
|
||||
started = true;
|
||||
signal?.throwIfAborted();
|
||||
return await run();
|
||||
} finally {
|
||||
releaseCurrent();
|
||||
// A canceled waiter must remain in the chain until its predecessor settles;
|
||||
// otherwise a later request can skip the still-active compaction.
|
||||
void queued.then(() => {
|
||||
if (codexNativeCompactionQueues.get(threadId) === queued) {
|
||||
codexNativeCompactionQueues.delete(threadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCodexNativeCompactionQueue(
|
||||
previous: Promise<void>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
return run();
|
||||
});
|
||||
if (!signal) {
|
||||
await previous.catch(() => undefined);
|
||||
return;
|
||||
return queued;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
let removeAbortListener = () => {};
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
const onAbort = () => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error("compaction aborted"));
|
||||
if (!started) {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error("compaction aborted"));
|
||||
}
|
||||
};
|
||||
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
await Promise.race([previous.catch(() => undefined), aborted]);
|
||||
// The canceled promise settles immediately, but its queued task remains
|
||||
// behind its predecessor so later compactions cannot overtake active work.
|
||||
return await Promise.race([queued, aborted]);
|
||||
} finally {
|
||||
removeAbortListener();
|
||||
}
|
||||
@@ -413,8 +403,7 @@ function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams
|
||||
inheritedRecord?: Record<string, unknown>;
|
||||
inheritedPath?: string;
|
||||
}> = [];
|
||||
const defaultCompaction = readRecord(readRecord(params.config?.agents)?.defaults)?.compaction;
|
||||
const defaultRecord = readRecord(defaultCompaction);
|
||||
const defaultRecord = asOptionalRecord(params.config?.agents?.defaults?.compaction);
|
||||
if (defaultRecord) {
|
||||
entries.push({ path: "agents.defaults", record: defaultRecord });
|
||||
}
|
||||
@@ -427,8 +416,7 @@ function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams
|
||||
const id = typeof agent?.id === "string" ? agent.id.trim().toLowerCase() : "";
|
||||
return id === agentId;
|
||||
});
|
||||
const agentCompaction = readRecord(activeAgent)?.compaction;
|
||||
const agentRecord = readRecord(agentCompaction);
|
||||
const agentRecord = asOptionalRecord(activeAgent?.compaction);
|
||||
if (agentRecord) {
|
||||
entries.push({
|
||||
path: `agents.list.${agentId}`,
|
||||
@@ -448,12 +436,6 @@ function readAgentIdFromSessionKey(sessionKey: string | undefined): string | und
|
||||
return parts[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function compactCodexNativeThread(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
options: CodexAppServerCompactOptions,
|
||||
@@ -464,22 +446,16 @@ async function compactCodexNativeThread(
|
||||
sessionKey: params.sessionKey,
|
||||
trigger: params.trigger,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
return codexNativeCompactionResult(params, {
|
||||
compacted: false,
|
||||
reason: "codex app-server owns automatic compaction",
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "non_manual_trigger",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
},
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: "non_manual_trigger",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: params.config,
|
||||
@@ -567,6 +543,21 @@ async function compactCodexNativeThread(
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
let releaseThreadSubscription: (() => Promise<void>) | undefined;
|
||||
const releaseCompactionThread = async (threadId: string) => {
|
||||
if (
|
||||
await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex compaction thread subscription could not be released: ${threadId}`,
|
||||
);
|
||||
};
|
||||
const completionWatch = watchCodexNativeCompactionCompletion({
|
||||
client,
|
||||
threadId: binding.threadId,
|
||||
@@ -575,6 +566,7 @@ async function compactCodexNativeThread(
|
||||
interruptGraceMs:
|
||||
options.nativeInterruptGraceMs ?? CODEX_NATIVE_COMPACTION_INTERRUPT_GRACE_MS,
|
||||
retireUnconfirmed: async () => {
|
||||
releaseThreadSubscription = undefined;
|
||||
const transportStopped = await client.closeAndWait({
|
||||
exitTimeoutMs: 5_000,
|
||||
forceKillDelayMs: 250,
|
||||
@@ -610,6 +602,22 @@ async function compactCodexNativeThread(
|
||||
throw new Error("failed to detach unconfirmed codex app-server thread binding");
|
||||
},
|
||||
});
|
||||
const acquireThreadSubscription = async (timeoutMs?: number) => {
|
||||
if (!isIncognitoSessionKey(params.sessionKey)) {
|
||||
// Remove any idle ownership first: sibling cleanup must not evict
|
||||
// this subscription while compaction still awaits terminal events.
|
||||
if (!(await consumeCodexAppServerLiveThread(client, binding.threadId))) {
|
||||
await resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient: async () => closeCodexStartupClientBestEffort(client),
|
||||
request: { threadId: binding.threadId, excludeTurns: true },
|
||||
timeoutMs: timeoutMs ?? appServer.requestTimeoutMs,
|
||||
...(params.abortSignal ? { signal: params.abortSignal } : {}),
|
||||
});
|
||||
}
|
||||
releaseThreadSubscription = async () => releaseCompactionThread(binding.threadId);
|
||||
}
|
||||
};
|
||||
const beginNativeCompactionRequest = async (timeoutMs?: number) => {
|
||||
completionWatch.beginRequest();
|
||||
const requestParams = { threadId: binding.threadId };
|
||||
@@ -668,6 +676,11 @@ async function compactCodexNativeThread(
|
||||
};
|
||||
}
|
||||
binding = currentBinding;
|
||||
const guardedRequestTimeoutMs = Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
await acquireThreadSubscription(guardedRequestTimeoutMs);
|
||||
await clearContextEngineProjectionBeforeNativeCompaction({
|
||||
sessionId: params.sessionId,
|
||||
bindingStore: options.bindingStore,
|
||||
@@ -675,12 +688,7 @@ async function compactCodexNativeThread(
|
||||
binding,
|
||||
});
|
||||
try {
|
||||
await beginNativeCompactionRequest(
|
||||
Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
await beginNativeCompactionRequest(guardedRequestTimeoutMs);
|
||||
return { started: true as const, accepted: true as const };
|
||||
} catch (error) {
|
||||
await options.bindingStore.mutate(bindingIdentity, {
|
||||
@@ -702,6 +710,7 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
} else {
|
||||
params.abortSignal?.throwIfAborted();
|
||||
await acquireThreadSubscription();
|
||||
try {
|
||||
await beginNativeCompactionRequest();
|
||||
} catch (error) {
|
||||
@@ -742,8 +751,12 @@ async function compactCodexNativeThread(
|
||||
};
|
||||
} finally {
|
||||
completionWatch.cancel();
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
try {
|
||||
await releaseThreadSubscription?.();
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
@@ -759,16 +772,7 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: resultDetails,
|
||||
},
|
||||
};
|
||||
return codexNativeCompactionResult(params, { compacted: true, details: resultDetails });
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -791,6 +795,23 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
}
|
||||
|
||||
function codexNativeCompactionResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
outcome: { compacted: boolean; reason?: string; details: JsonObject },
|
||||
): EmbeddedAgentCompactResult {
|
||||
return {
|
||||
ok: true,
|
||||
compacted: outcome.compacted,
|
||||
...(outcome.reason ? { reason: outcome.reason } : {}),
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: outcome.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function skippedCodexNativeCompactionResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
skipped: {
|
||||
@@ -800,25 +821,19 @@ function skippedCodexNativeCompactionResult(
|
||||
currentThreadId?: string;
|
||||
},
|
||||
): EmbeddedAgentCompactResult {
|
||||
return {
|
||||
ok: true,
|
||||
return codexNativeCompactionResult(params, {
|
||||
compacted: false,
|
||||
reason: skipped.reason,
|
||||
result: {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: skipped.code,
|
||||
request: "after_context_engine",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
...(skipped.expectedThreadId ? { expectedThreadId: skipped.expectedThreadId } : {}),
|
||||
...(skipped.currentThreadId ? { currentThreadId: skipped.currentThreadId } : {}),
|
||||
},
|
||||
details: {
|
||||
backend: "codex-app-server",
|
||||
skipped: true,
|
||||
reason: skipped.code,
|
||||
request: "after_context_engine",
|
||||
trigger: params.trigger ?? "unknown",
|
||||
...(skipped.expectedThreadId ? { expectedThreadId: skipped.expectedThreadId } : {}),
|
||||
...(skipped.currentThreadId ? { currentThreadId: skipped.currentThreadId } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function failedCodexThreadBindingCompactionResult(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveCodexAppServerExecutionCwd,
|
||||
resolveCodexExternalSandboxPolicyForOpenClawSandbox,
|
||||
resolveCodexMessageToolProvider,
|
||||
resolveCodexSandboxEnvironmentSelection,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
} from "./dynamic-tool-build.js";
|
||||
import {
|
||||
@@ -188,6 +189,42 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
).toBe("discord");
|
||||
});
|
||||
|
||||
const sandboxEnvironment = { environmentId: "sandbox-1", cwd: "/workspace" };
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "restricted without a sandbox",
|
||||
environment: undefined,
|
||||
nativeToolSurfaceEnabled: false,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: "restricted with a sandbox",
|
||||
environment: sandboxEnvironment,
|
||||
nativeToolSurfaceEnabled: false,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: "native without a sandbox",
|
||||
environment: undefined,
|
||||
nativeToolSurfaceEnabled: true,
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "native with a sandbox",
|
||||
environment: sandboxEnvironment,
|
||||
nativeToolSurfaceEnabled: true,
|
||||
expected: [sandboxEnvironment],
|
||||
},
|
||||
])("preserves the explicit Codex environment selection when $name", (testCase) => {
|
||||
expect(
|
||||
resolveCodexSandboxEnvironmentSelection(
|
||||
testCase.environment,
|
||||
testCase.nativeToolSurfaceEnabled,
|
||||
),
|
||||
).toEqual(testCase.expected);
|
||||
});
|
||||
|
||||
it("maps sandbox exec-server cwd through the remote workspace mapping", () => {
|
||||
expect(
|
||||
resolveCodexAppServerExecutionCwd({
|
||||
|
||||
@@ -614,7 +614,9 @@ export function resolveCodexSandboxEnvironmentSelection(
|
||||
environment: CodexSandboxExecEnvironment | undefined,
|
||||
nativeToolSurfaceEnabled: boolean,
|
||||
): CodexTurnEnvironmentParams[] | undefined {
|
||||
return environment && nativeToolSurfaceEnabled ? [environment] : undefined;
|
||||
// Omitting this selection while a turn sets cwd restores Codex's local
|
||||
// environment; an explicit empty selection keeps native tools disabled.
|
||||
return nativeToolSurfaceEnabled ? (environment ? [environment] : undefined) : [];
|
||||
}
|
||||
/** Chooses the cwd visible to Codex native execution after sandbox exec-server setup. */
|
||||
export function resolveCodexAppServerExecutionCwd(params: {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state";
|
||||
import { estimateToolResultTextChars } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import {
|
||||
@@ -928,6 +929,73 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
expect(text).toContain("rerun with narrower args");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "ASCII", character: "a", truncated: false },
|
||||
{ label: "dense CJK", character: "你", truncated: true },
|
||||
])(
|
||||
"budgets $label tool results by their shared token weight",
|
||||
async ({ character, truncated }) => {
|
||||
const original = character.repeat(16_000);
|
||||
const bridge = createBridgeWithToolResult("large_lookup", textToolResult(original), {
|
||||
contextWindowTokens: 128_000,
|
||||
});
|
||||
|
||||
const result = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-weighted",
|
||||
namespace: null,
|
||||
tool: "large_lookup",
|
||||
arguments: {},
|
||||
});
|
||||
const firstItem = result.contentItems[0];
|
||||
if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") {
|
||||
throw new Error("expected inputText tool result");
|
||||
}
|
||||
expect(estimateToolResultTextChars(firstItem.text)).toBeLessThanOrEqual(32_000);
|
||||
if (truncated) {
|
||||
expect(firstItem.text).toContain("original 16000 chars, weighted budget 32000");
|
||||
expect(firstItem.text.length).toBeLessThan(9_000);
|
||||
} else {
|
||||
expect(firstItem.text).toBe(original);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "missing", contextWindowTokens: undefined, maxChars: 16_000 },
|
||||
{ label: "zero", contextWindowTokens: 0, maxChars: 16_000 },
|
||||
{ label: "negative", contextWindowTokens: -1, maxChars: 16_000 },
|
||||
{ label: "non-finite", contextWindowTokens: Number.POSITIVE_INFINITY, maxChars: 16_000 },
|
||||
{ label: "tiny", contextWindowTokens: 1, maxChars: 1 },
|
||||
{ label: "extra-large", contextWindowTokens: 200_000, maxChars: 64_000 },
|
||||
])(
|
||||
"preserves the canonical cap for $label contexts",
|
||||
async ({ contextWindowTokens, maxChars }) => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"large_lookup",
|
||||
textToolResult("x".repeat(70_000)),
|
||||
{
|
||||
contextWindowTokens,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-context-cap",
|
||||
namespace: null,
|
||||
tool: "large_lookup",
|
||||
arguments: {},
|
||||
});
|
||||
const firstItem = result.contentItems[0];
|
||||
if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") {
|
||||
throw new Error("expected inputText tool result");
|
||||
}
|
||||
expect(firstItem.text.length).toBe(maxChars);
|
||||
},
|
||||
);
|
||||
|
||||
it("applies the context-share ceiling for small effective windows", async () => {
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
@@ -959,7 +1027,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
it("keeps a whole code point when dynamic tool text crosses the automatic boundary", async () => {
|
||||
const maxChars = 16_000;
|
||||
const totalChars = 20_000;
|
||||
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`;
|
||||
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, weighted budget ${maxChars}; rerun with narrower args.)`;
|
||||
const textBudget = maxChars - noticeText.length - 1;
|
||||
const prefix = "a".repeat(textBudget - 1);
|
||||
const longText = `${prefix}😀${"z".repeat(totalChars - prefix.length - 2)}`;
|
||||
@@ -1021,6 +1089,42 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
expect(text).not.toContain("b".repeat(10_000));
|
||||
});
|
||||
|
||||
it("shares weighted budget across mixed text blocks while preserving images", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"mixed_lookup",
|
||||
{
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(4_000) },
|
||||
{ type: "image", mimeType: "image/png", data: COMPUTER_FRAME_IMAGE },
|
||||
{ type: "text", text: "你".repeat(9_000) },
|
||||
],
|
||||
details: {},
|
||||
},
|
||||
{ contextWindowTokens: 128_000 },
|
||||
);
|
||||
|
||||
const result = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-mixed-weighted",
|
||||
namespace: null,
|
||||
tool: "mixed_lookup",
|
||||
arguments: {},
|
||||
});
|
||||
const text = result.contentItems
|
||||
.map((item) => (item.type === "inputText" && typeof item.text === "string" ? item.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(result.contentItems.map((item) => item.type)).toEqual([
|
||||
"inputText",
|
||||
"inputImage",
|
||||
"inputText",
|
||||
]);
|
||||
expect(result.contentItems[0]).toEqual({ type: "inputText", text: "a".repeat(4_000) });
|
||||
expect(estimateToolResultTextChars(text)).toBeLessThanOrEqual(32_000);
|
||||
expect(text).toContain("original 13000 chars, weighted budget 32000");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ toolName: "tts", mediaUrl: "/tmp/reply.opus", audioAsVoice: true },
|
||||
{ toolName: "image_generate", mediaUrl: "/tmp/generated.png" },
|
||||
|
||||
@@ -46,7 +46,12 @@ import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
|
||||
import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
|
||||
estimateToolResultTextChars,
|
||||
resolveLiveToolResultMaxChars,
|
||||
sliceToolResultTextToBudget,
|
||||
} from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { CodexDynamicToolsLoading } from "./config.js";
|
||||
import {
|
||||
createFailedDynamicToolResponse,
|
||||
@@ -398,29 +403,6 @@ const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
const LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 32_000;
|
||||
const XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 64_000;
|
||||
|
||||
function resolveCodexDynamicToolResultMaxChars(contextWindowTokens?: number): number {
|
||||
if (
|
||||
typeof contextWindowTokens !== "number" ||
|
||||
!Number.isFinite(contextWindowTokens) ||
|
||||
contextWindowTokens <= 0
|
||||
) {
|
||||
return DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
|
||||
}
|
||||
const tokens = Math.floor(contextWindowTokens);
|
||||
const autoCap =
|
||||
tokens >= 200_000
|
||||
? XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS
|
||||
: tokens >= 100_000
|
||||
? LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS
|
||||
: DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
|
||||
// Match the core live-result context-share ceiling without importing core
|
||||
// internals across the bundled-plugin boundary.
|
||||
return Math.min(autoCap, Math.max(1, Math.floor(tokens * 0.3) * 4));
|
||||
}
|
||||
|
||||
function computerFrameImageIdentity(
|
||||
content: AgentToolResult<unknown>["content"] | undefined,
|
||||
@@ -466,9 +448,13 @@ export function createCodexDynamicToolBridge(params: {
|
||||
directToolNames?: Iterable<string>;
|
||||
}): CodexDynamicToolBridge {
|
||||
const toolResultHookContext = toToolResultHookContext(params.hookContext);
|
||||
const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(
|
||||
params.hookContext?.contextWindowTokens,
|
||||
);
|
||||
const contextWindowTokens = params.hookContext?.contextWindowTokens;
|
||||
const toolResultMaxChars =
|
||||
typeof contextWindowTokens === "number" &&
|
||||
Number.isFinite(contextWindowTokens) &&
|
||||
contextWindowTokens > 0
|
||||
? Math.max(1, resolveLiveToolResultMaxChars({ contextWindowTokens }))
|
||||
: DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
|
||||
const availableProjection = projectCodexDynamicTools(params.tools);
|
||||
const registeredProjection = params.registeredTools
|
||||
? projectCodexDynamicTools(params.registeredTools)
|
||||
@@ -1433,23 +1419,28 @@ function withDynamicToolAsyncStarted<T extends CodexDynamicToolCallResponse>(
|
||||
function normalizeToolResultMaxChars(maxChars: number): number {
|
||||
return typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0
|
||||
? Math.floor(maxChars)
|
||||
: DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
|
||||
: DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
|
||||
}
|
||||
function convertToolContents(
|
||||
content: Array<TextContent | ImageContent>,
|
||||
toolResultMaxChars = DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS,
|
||||
toolResultMaxChars = DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
|
||||
): CodexDynamicToolCallOutputContentItem[] {
|
||||
const maxChars = normalizeToolResultMaxChars(toolResultMaxChars);
|
||||
const totalTextChars = content.reduce(
|
||||
(total, item) => total + (item.type === "text" ? item.text.length : 0),
|
||||
0,
|
||||
);
|
||||
if (totalTextChars <= maxChars) {
|
||||
const totalTextBudget = content.reduce(
|
||||
(total, item) => total + (item.type === "text" ? estimateToolResultTextChars(item.text) : 0),
|
||||
0,
|
||||
);
|
||||
if (totalTextBudget <= maxChars) {
|
||||
return content.flatMap(convertToolContent);
|
||||
}
|
||||
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalTextChars} chars, showing ${maxChars}; rerun with narrower args.)`;
|
||||
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalTextChars} chars, weighted budget ${maxChars}; rerun with narrower args.)`;
|
||||
const notice = `\n${noticeText}`;
|
||||
const textBudget = Math.max(0, maxChars - notice.length);
|
||||
const noticeChars = estimateToolResultTextChars(notice);
|
||||
const textBudget = Math.max(0, maxChars - noticeChars);
|
||||
let remainingTextBudget = textBudget;
|
||||
let appendedNotice = false;
|
||||
const output: CodexDynamicToolCallOutputContentItem[] = [];
|
||||
@@ -1461,15 +1452,14 @@ function convertToolContents(
|
||||
if (appendedNotice) {
|
||||
continue;
|
||||
}
|
||||
if (notice.length >= maxChars) {
|
||||
output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) });
|
||||
if (noticeChars >= maxChars) {
|
||||
output.push({ type: "inputText", text: sliceToolResultTextToBudget(noticeText, maxChars) });
|
||||
appendedNotice = true;
|
||||
continue;
|
||||
}
|
||||
const sliceLength = Math.min(item.text.length, remainingTextBudget);
|
||||
remainingTextBudget -= sliceLength;
|
||||
const shouldAppendNotice = remainingTextBudget <= 0;
|
||||
const text = truncateUtf16Safe(item.text, sliceLength);
|
||||
const text = sliceToolResultTextToBudget(item.text, remainingTextBudget);
|
||||
remainingTextBudget -= estimateToolResultTextChars(text);
|
||||
const shouldAppendNotice = remainingTextBudget <= 0 || text.length < item.text.length;
|
||||
if (shouldAppendNotice) {
|
||||
// The notice budget is reserved before slicing text, so the combined
|
||||
// result is already bounded without another boundary-sensitive cut.
|
||||
@@ -1480,7 +1470,7 @@ function convertToolContents(
|
||||
}
|
||||
}
|
||||
if (!appendedNotice) {
|
||||
output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) });
|
||||
output.push({ type: "inputText", text: sliceToolResultTextToBudget(noticeText, maxChars) });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,13 @@ export class CodexAssistantProjection {
|
||||
this.assistantTextByItem.set(typedItemId, text);
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
if (
|
||||
text === undefined ||
|
||||
(!text &&
|
||||
(phase === "commentary" ||
|
||||
activeItemIds.size > 0 ||
|
||||
readString(item, "type") !== "message"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const itemId = rawItemId ?? `raw-assistant-${this.assistantItemOrder.length + 1}`;
|
||||
@@ -263,6 +269,7 @@ export class CodexAssistantProjection {
|
||||
pendingTerminalAssistantEchoItemId === undefined &&
|
||||
activeItemIds.size === 0;
|
||||
if (
|
||||
text &&
|
||||
phase !== "commentary" &&
|
||||
candidateWasSupersededBeforeRaw &&
|
||||
itemId !== this.streamedPartialAssistantItemId &&
|
||||
@@ -275,6 +282,10 @@ export class CodexAssistantProjection {
|
||||
}
|
||||
this.rememberAssistantItem(itemId);
|
||||
this.assistantTextByItem.set(itemId, text);
|
||||
// Empty raw finals prove an actual stop; retain that fact without publishing fake output.
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
this.rawPromotedAssistantItemIds.add(itemId);
|
||||
if (phase === "commentary") {
|
||||
this.emitCommentaryProgress({ itemId, text });
|
||||
|
||||
@@ -82,20 +82,18 @@ export function splitPlanText(text: string): string[] {
|
||||
|
||||
export function extractRawAssistantText(item: JsonObject): string | undefined {
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
const text = content
|
||||
.flatMap((entry) => {
|
||||
if (!isJsonObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
const type = readString(entry, "type");
|
||||
if (type !== "output_text" && type !== "text") {
|
||||
return [];
|
||||
}
|
||||
const value = readString(entry, "text");
|
||||
return value ? [value] : [];
|
||||
})
|
||||
.join("");
|
||||
return text.trim() || undefined;
|
||||
const parts = content.flatMap((entry) => {
|
||||
if (!isJsonObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
const type = readString(entry, "type");
|
||||
if (type !== "output_text" && type !== "text") {
|
||||
return [];
|
||||
}
|
||||
const value = readString(entry, "text");
|
||||
return value === undefined ? [] : [value];
|
||||
});
|
||||
return parts.length > 0 ? parts.join("").trim() : undefined;
|
||||
}
|
||||
|
||||
export function readItemString(item: CodexThreadItem, key: string): string | undefined {
|
||||
|
||||
@@ -98,6 +98,111 @@ describe("CodexAppServerEventProjector commentary projection", () => {
|
||||
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ itemId: "msg_mock_1", text: "" },
|
||||
{ itemId: "msg_mock_1", text: " \n " },
|
||||
{ itemId: undefined, text: "" },
|
||||
{ itemId: undefined, text: " \n " },
|
||||
])(
|
||||
"preserves an explicit raw empty stop ($itemId) after a settled write",
|
||||
async ({ itemId, text }) => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
const priorAssistant = { type: "agentMessage", id: "msg-before-write", text: "" };
|
||||
await projector.handleNotification(forCurrentTurn("item/started", { item: priorAssistant }));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", { item: priorAssistant }),
|
||||
);
|
||||
const item = {
|
||||
type: "dynamicToolCall",
|
||||
id: "call-write",
|
||||
namespace: null,
|
||||
tool: "write",
|
||||
arguments: { path: "note.txt", content: "written once" },
|
||||
status: "inProgress",
|
||||
contentItems: null,
|
||||
success: null,
|
||||
durationMs: null,
|
||||
};
|
||||
await projector.handleNotification(forCurrentTurn("item/started", { item }));
|
||||
projector.recordDynamicToolCall({
|
||||
callId: item.id,
|
||||
tool: item.tool,
|
||||
arguments: item.arguments,
|
||||
});
|
||||
projector.recordDynamicToolResult({
|
||||
callId: item.id,
|
||||
tool: item.tool,
|
||||
success: true,
|
||||
sideEffectEvidence: true,
|
||||
contentItems: [{ type: "inputText", text: "written once" }],
|
||||
});
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
...item,
|
||||
status: "completed",
|
||||
contentItems: [{ type: "inputText", text: "written once" }],
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "message",
|
||||
...(itemId ? { id: itemId } : {}),
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(turnCompleted([]));
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
expect(result.assistantTexts).toEqual([]);
|
||||
expect(result.lastAssistant).toBeUndefined();
|
||||
expect(result.currentAttemptAssistant).toMatchObject({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "" }],
|
||||
});
|
||||
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
|
||||
expect(result.itemLifecycle).toEqual({ startedCount: 2, completedCount: 2, activeCount: 0 });
|
||||
expect(result.messagesSnapshot.filter((message) => message.role === "assistant")).toEqual([
|
||||
expect.objectContaining({ content: [expect.objectContaining({ type: "toolCall" })] }),
|
||||
]);
|
||||
expect(onAgentEvent.mock.calls.some(([event]) => event.stream === "assistant")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "missing content", content: [] },
|
||||
{ label: "missing text", content: [{ type: "output_text" }] },
|
||||
{ label: "non-text content", content: [{ type: "reasoning", text: "" }] },
|
||||
{ label: "commentary", content: [{ type: "output_text", text: "" }], phase: "commentary" },
|
||||
{ label: "active tool", content: [{ type: "output_text", text: "" }], active: true },
|
||||
])("does not fabricate a terminal assistant for $label", async ({ content, phase, active }) => {
|
||||
const projector = await createProjector();
|
||||
if (active) {
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "commandExecution", id: "pending-tool", status: "inProgress" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: { type: "message", role: "assistant", ...(phase ? { phase } : {}), content },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(turnCompleted([]));
|
||||
|
||||
expect(
|
||||
projector.buildResult(buildEmptyToolTelemetry()).currentAttemptAssistant,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("streams commentary agent messages as keyed progress events", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
|
||||
@@ -75,6 +75,7 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) {
|
||||
| CodexNativePreToolUseFailure["disposition"]
|
||||
| undefined,
|
||||
releaseSharedClientLease: undefined as (() => void) | undefined,
|
||||
startupClientUnsafe: false,
|
||||
sharedCodexClientRetiredForOneShotCleanup: false,
|
||||
sandboxExecEnvironmentAcquired: false,
|
||||
codexEnvironmentSelection: undefined as CodexTurnEnvironmentParams[] | undefined,
|
||||
|
||||
@@ -328,7 +328,16 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
expect(requests[0]?.params).toEqual(expect.objectContaining({ ephemeral: true }));
|
||||
});
|
||||
|
||||
it("unsubscribes an incognito Codex thread when turn start fails", async () => {
|
||||
it.each([
|
||||
{ reason: "fails", error: new Error("turn start exploded") },
|
||||
{
|
||||
reason: "is cancelled before its request is written",
|
||||
error: Object.assign(new Error("turn/start aborted"), {
|
||||
code: "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED",
|
||||
mayHaveWritten: false,
|
||||
}),
|
||||
},
|
||||
])("unsubscribes an incognito Codex thread when turn start $reason", async ({ error }) => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionKey = "agent:main:dashboard:incognito-failed-turn";
|
||||
@@ -339,7 +348,7 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
throw new Error("turn start exploded");
|
||||
throw error;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
@@ -359,7 +368,7 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
clientFactory,
|
||||
}),
|
||||
).rejects.toThrow("turn start exploded");
|
||||
).rejects.toThrow(error.message);
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
@@ -373,6 +382,115 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "confirms", interruptFails: false },
|
||||
{ label: "cannot confirm", interruptFails: true },
|
||||
])(
|
||||
"$label an indeterminate native turn before releasing its thread",
|
||||
async ({ interruptFails }) => {
|
||||
const sessionFile = path.join(tempDir, "cancelled-start-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "cancelled-start-workspace");
|
||||
const harness = createClientHarness();
|
||||
const abort = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client);
|
||||
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.abortSignal = abort.signal;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
const failure = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
const initialize = await waitForHarnessRequest(harness, "initialize");
|
||||
harness.send({
|
||||
id: initialize.id,
|
||||
result: { userAgent: `openclaw/${CODEX_APP_SERVER_VERSION} (macOS; test)` },
|
||||
});
|
||||
const threadStart = await waitForHarnessRequest(harness, "thread/start");
|
||||
harness.send({ id: threadStart.id, result: threadStartResult() });
|
||||
const turnStart = await waitForHarnessRequest(harness, "turn/start");
|
||||
|
||||
abort.abort("cancelled");
|
||||
const interrupt = await waitForHarnessRequest(harness, "turn/interrupt");
|
||||
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toMatchObject({
|
||||
method: "turn/interrupt",
|
||||
params: { threadId: "thread-1", turnId: "" },
|
||||
});
|
||||
harness.send({ id: turnStart.id, result: turnStartResult() });
|
||||
harness.send(
|
||||
interruptFails
|
||||
? { id: interrupt.id, error: { code: -32_000, message: "startup interrupt failed" } }
|
||||
: { id: interrupt.id, result: {} },
|
||||
);
|
||||
if (!interruptFails) {
|
||||
const unsubscribe = await waitForHarnessRequest(harness, "thread/unsubscribe");
|
||||
harness.send({ id: unsubscribe.id, result: {} });
|
||||
}
|
||||
await expect(failure).resolves.toMatchObject({ message: "turn/start aborted" });
|
||||
expect(harness.writes.map((entry) => JSON.parse(entry).method)).toEqual([
|
||||
"initialize",
|
||||
"initialized",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/interrupt",
|
||||
...(!interruptFails ? ["thread/unsubscribe"] : []),
|
||||
]);
|
||||
expect(harness.stdinDestroyed).toBe(interruptFails);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves startup cancellation when unsafe client retirement rejects", async () => {
|
||||
const sessionFile = path.join(tempDir, "retirement-failure-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "retirement-failure-workspace");
|
||||
const startupError = Object.assign(new Error("turn/start aborted"), {
|
||||
code: "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED",
|
||||
mayHaveWritten: true,
|
||||
});
|
||||
const close = vi.fn();
|
||||
const closeAndWait = vi.fn(async () => {
|
||||
throw new Error("client retirement failed");
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
throw startupError;
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
throw new Error("startup interrupt failed");
|
||||
}
|
||||
throw new Error(`unexpected cleanup request: ${method}`);
|
||||
});
|
||||
const clientFactory: CodexAppServerClientFactory = multiplexedClientFactory(async () => {
|
||||
return {
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
close,
|
||||
closeAndWait,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
clientFactory,
|
||||
}),
|
||||
).rejects.toBe(startupError);
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/interrupt",
|
||||
]);
|
||||
expect(closeAndWait).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an interrupted shared turn subscribed until its exact terminal arrives", async () => {
|
||||
const sessionFile = path.join(tempDir, "cancelled-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "cancelled-workspace");
|
||||
@@ -404,6 +522,10 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
|
||||
abort.abort("cancelled");
|
||||
const interrupt = await waitForHarnessRequest(harness, "turn/interrupt");
|
||||
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toMatchObject({
|
||||
method: "turn/interrupt",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
harness.send({ id: interrupt.id, result: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { embeddedAgentLog, formatErrorMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
interruptCodexTurnAndWaitBestEffort,
|
||||
retireUnsafeCodexTurnClientBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import {
|
||||
createCodexModelCallDiagnosticEmitter,
|
||||
utf8JsonByteLength,
|
||||
} from "./attempt-diagnostics.js";
|
||||
import { isCodexAppServerIndeterminateRequestCancellationError } from "./client.js";
|
||||
import { assertCodexTurnStartResponse } from "./protocol-validators.js";
|
||||
import type { CodexTurnStartResponse } from "./protocol.js";
|
||||
import { readCodexRateLimitsRevision } from "./rate-limit-cache.js";
|
||||
@@ -136,9 +141,20 @@ export async function prepareCodexAttemptTurnRequest(
|
||||
throwIfTurnStartAcceptedAfterAbort();
|
||||
return startedTurn;
|
||||
} catch (error) {
|
||||
if (acceptedTurnId) {
|
||||
await turnRuntime.interruptTurn(acceptedTurnId);
|
||||
releaseCurrentRoute();
|
||||
if (acceptedTurnId || isCodexAppServerIndeterminateRequestCancellationError(error)) {
|
||||
// Codex serializes start/interrupt per thread; an empty id interrupts
|
||||
// the accepted native turn even when local cancellation hid its response.
|
||||
try {
|
||||
resourceState.startupClientUnsafe = !(await interruptCodexTurnAndWaitBestEffort(
|
||||
resourceState.client,
|
||||
{ threadId: resourceState.thread.threadId, turnId: acceptedTurnId ?? "" },
|
||||
));
|
||||
if (resourceState.startupClientUnsafe) {
|
||||
await retireUnsafeCodexTurnClientBestEffort(resourceState.client, "startup interrupt");
|
||||
}
|
||||
} finally {
|
||||
releaseCurrentRoute();
|
||||
}
|
||||
} else {
|
||||
await activeTurnRoute.cancelTurn();
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ export async function startCodexAttemptTurn(
|
||||
threadId: resourceState.thread.threadId,
|
||||
})
|
||||
: true;
|
||||
if (!state.timedOut && bindingReleased) {
|
||||
if (!state.timedOut && bindingReleased && !resourceState.startupClientUnsafe) {
|
||||
const released = await unsubscribeCodexThreadBestEffort(resourceState.client, {
|
||||
threadId: resourceState.thread.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureCodexSettledTurnFinalizationContext } from "./settled-turn-context.js";
|
||||
import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js";
|
||||
import { attachCodexMirrorAttestation } from "./transcript-mirror-attestation.js";
|
||||
import {
|
||||
attachCodexMirrorIdentity,
|
||||
attachUpstreamUserText,
|
||||
readMirrorIdentity,
|
||||
readUpstreamUserText,
|
||||
} from "./upstream-prompt-provenance.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readHistory: vi.fn(),
|
||||
@@ -37,6 +43,30 @@ function settledTurn() {
|
||||
];
|
||||
}
|
||||
|
||||
function settledHostPromptTurn() {
|
||||
const settledMessages = settledTurn();
|
||||
settledMessages[0] = attachUpstreamUserText(
|
||||
message(
|
||||
{ role: "user", content: "Send it.", idempotencyKey: "durable-user-turn" },
|
||||
"turn-2:prompt",
|
||||
),
|
||||
"Decorated upstream prompt: Send it.",
|
||||
);
|
||||
const persistedPrompt = {
|
||||
role: "user",
|
||||
content: "Send it.",
|
||||
timestamp: 1,
|
||||
idempotencyKey: "durable-user-turn",
|
||||
__openclaw: { senderIsOwner: true, transport: { messageId: "transport-message" } },
|
||||
} as AgentMessage;
|
||||
return {
|
||||
settledMessages,
|
||||
persistedPrompt,
|
||||
historyMessages: [persistedPrompt, ...settledMessages.slice(1)],
|
||||
mirroredMessages: settledMessages.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureContext(params: {
|
||||
historyMessages: AgentMessage[];
|
||||
mirroredMessages: AgentMessage[];
|
||||
@@ -79,6 +109,61 @@ describe("captureCodexSettledTurnFinalizationContext", () => {
|
||||
expect(context?.messages).not.toBe(historyMessages);
|
||||
});
|
||||
|
||||
it("adopts an exact host-persisted prompt without rewriting its canonical metadata", async () => {
|
||||
const { persistedPrompt, ...turn } = settledHostPromptTurn();
|
||||
|
||||
const context = await captureContext(turn);
|
||||
|
||||
expect(context).toEqual({ source: "openclaw-transcript", messages: turn.historyMessages });
|
||||
expect(Object.isFrozen(context?.messages)).toBe(true);
|
||||
expect(context?.messages[0]).toEqual(persistedPrompt);
|
||||
expect(readMirrorIdentity(context!.messages[0]!)).toBeUndefined();
|
||||
expect(readUpstreamUserText(context!.messages[0]!)).toBeUndefined();
|
||||
expect(context?.messages[0]).toMatchObject({
|
||||
__openclaw: { senderIsOwner: true, transport: { messageId: "transport-message" } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "missing persisted key",
|
||||
change: (prompt: AgentMessage) => ({ ...prompt, idempotencyKey: undefined }),
|
||||
},
|
||||
{
|
||||
name: "different persisted key",
|
||||
change: (prompt: AgentMessage) => ({ ...prompt, idempotencyKey: "different-user-turn" }),
|
||||
},
|
||||
{
|
||||
name: "changed prompt content",
|
||||
change: (prompt: AgentMessage) => ({ ...prompt, content: "Send something else." }),
|
||||
},
|
||||
{
|
||||
name: "conflicting mirror identity",
|
||||
change: (prompt: AgentMessage) => attachCodexMirrorIdentity(prompt, "foreign-turn:prompt"),
|
||||
},
|
||||
{
|
||||
name: "stale Codex mirror attestation",
|
||||
change: (prompt: AgentMessage) => attachCodexMirrorAttestation(prompt, "stale-fingerprint"),
|
||||
},
|
||||
{
|
||||
name: "conflicting upstream prompt",
|
||||
change: (prompt: AgentMessage) =>
|
||||
attachUpstreamUserText(prompt, "Untrusted upstream prompt."),
|
||||
},
|
||||
])("rejects host-persisted prompt adoption with $name", async ({ change }) => {
|
||||
const turn = settledHostPromptTurn();
|
||||
turn.historyMessages[0] = change(turn.persistedPrompt) as AgentMessage;
|
||||
|
||||
await expect(captureContext(turn)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects duplicate host-persisted prompt idempotency keys", async () => {
|
||||
const turn = settledHostPromptTurn();
|
||||
turn.historyMessages.unshift({ ...turn.persistedPrompt });
|
||||
|
||||
await expect(captureContext(turn)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "missing current prompt",
|
||||
|
||||
@@ -8,8 +8,16 @@ import {
|
||||
readCodexMirroredSessionHistoryMessages,
|
||||
type CodexMirroredSessionHistoryTarget,
|
||||
} from "./session-history.js";
|
||||
import { serializeCodexMirrorSourceEvidence } from "./transcript-mirror-attestation.js";
|
||||
import { readMirrorIdentity } from "./upstream-prompt-provenance.js";
|
||||
import {
|
||||
readCodexMirrorSourceFingerprint,
|
||||
serializeCodexMirrorSourceEvidence,
|
||||
} from "./transcript-mirror-attestation.js";
|
||||
import {
|
||||
attachCodexMirrorIdentity,
|
||||
attachUpstreamUserText,
|
||||
readMirrorIdentity,
|
||||
readUpstreamUserText,
|
||||
} from "./upstream-prompt-provenance.js";
|
||||
|
||||
type SettledTurnFinalizationContext = EmbeddedRunAttemptResult["settledTurnFinalizationContext"];
|
||||
|
||||
@@ -30,6 +38,66 @@ function collectUniqueMessageIdentities(
|
||||
return identities;
|
||||
}
|
||||
|
||||
function adoptPersistedHostPrompt(params: {
|
||||
historyMessages: readonly AgentMessage[];
|
||||
mirroredMessages: readonly AgentMessage[];
|
||||
settledMessages: readonly AgentMessage[];
|
||||
turnId: string;
|
||||
}): { historyMessages: readonly AgentMessage[]; mirroredMessages: readonly AgentMessage[] } {
|
||||
const promptIdentity = `${params.turnId}:prompt`;
|
||||
if (params.mirroredMessages.some((message) => readMirrorIdentity(message) === promptIdentity)) {
|
||||
return params;
|
||||
}
|
||||
const sourcePrompt = params.settledMessages[0];
|
||||
const sourceKey = (sourcePrompt as { idempotencyKey?: unknown } | undefined)?.idempotencyKey;
|
||||
if (
|
||||
sourcePrompt?.role !== "user" ||
|
||||
readMirrorIdentity(sourcePrompt) !== promptIdentity ||
|
||||
typeof sourceKey !== "string" ||
|
||||
sourceKey.trim().length === 0
|
||||
) {
|
||||
return params;
|
||||
}
|
||||
const matches = params.historyMessages.flatMap((message, index) =>
|
||||
message.role === "user" &&
|
||||
(message as { idempotencyKey?: unknown }).idempotencyKey === sourceKey
|
||||
? [{ index, message }]
|
||||
: [],
|
||||
);
|
||||
const persistedPrompt = matches.length === 1 ? matches[0] : undefined;
|
||||
const persistedMetadata = persistedPrompt?.message as
|
||||
| { __openclaw?: { mirrorOrigin?: unknown } }
|
||||
| undefined;
|
||||
if (
|
||||
!persistedPrompt ||
|
||||
readMirrorIdentity(persistedPrompt.message) !== undefined ||
|
||||
readCodexMirrorSourceFingerprint(persistedPrompt.message) !== undefined ||
|
||||
persistedMetadata?.["__openclaw"]?.mirrorOrigin === "codex-app-server"
|
||||
) {
|
||||
return params;
|
||||
}
|
||||
const sourceUpstreamText = readUpstreamUserText(sourcePrompt);
|
||||
const persistedUpstreamText = readUpstreamUserText(persistedPrompt.message);
|
||||
if (persistedUpstreamText !== undefined && persistedUpstreamText !== sourceUpstreamText) {
|
||||
return params;
|
||||
}
|
||||
let logicalPrompt = attachCodexMirrorIdentity(persistedPrompt.message, promptIdentity);
|
||||
if (sourceUpstreamText !== undefined) {
|
||||
logicalPrompt = attachUpstreamUserText(logicalPrompt, sourceUpstreamText);
|
||||
}
|
||||
if (
|
||||
serializeCodexMirrorSourceEvidence(logicalPrompt) !==
|
||||
serializeCodexMirrorSourceEvidence(sourcePrompt)
|
||||
) {
|
||||
return params;
|
||||
}
|
||||
// Host-owned prompts are authoritative by durable turn key; adopt their identity
|
||||
// only in verification views so canonical channel metadata remains untouched.
|
||||
const historyMessages = [...params.historyMessages];
|
||||
historyMessages[persistedPrompt.index] = logicalPrompt;
|
||||
return { historyMessages, mirroredMessages: [logicalPrompt, ...params.mirroredMessages] };
|
||||
}
|
||||
|
||||
/** Freezes one complete active transcript branch through the settled tool-result boundary. */
|
||||
function buildCodexSettledTurnFinalizationContext(params: {
|
||||
historyMessages: readonly AgentMessage[];
|
||||
@@ -37,6 +105,7 @@ function buildCodexSettledTurnFinalizationContext(params: {
|
||||
settledMessages: readonly AgentMessage[];
|
||||
turnId: string;
|
||||
}): SettledTurnFinalizationContext | undefined {
|
||||
const { historyMessages, mirroredMessages } = adoptPersistedHostPrompt(params);
|
||||
const boundaryMessage = params.settledMessages.findLast(
|
||||
(message) => message.role === "toolResult",
|
||||
);
|
||||
@@ -62,8 +131,8 @@ function buildCodexSettledTurnFinalizationContext(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const historyIdentities = collectUniqueMessageIdentities(params.historyMessages);
|
||||
const mirroredIdentities = collectUniqueMessageIdentities(params.mirroredMessages);
|
||||
const historyIdentities = collectUniqueMessageIdentities(historyMessages);
|
||||
const mirroredIdentities = collectUniqueMessageIdentities(mirroredMessages);
|
||||
if (!historyIdentities || !mirroredIdentities) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -71,7 +140,7 @@ function buildCodexSettledTurnFinalizationContext(params: {
|
||||
if (mirroredBoundaryIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const mirroredThroughBoundary = params.mirroredMessages.slice(0, mirroredBoundaryIndex + 1);
|
||||
const mirroredThroughBoundary = mirroredMessages.slice(0, mirroredBoundaryIndex + 1);
|
||||
if (
|
||||
mirroredThroughBoundary.length !== requiredIdentities.length ||
|
||||
mirroredThroughBoundary.some(
|
||||
@@ -88,8 +157,7 @@ function buildCodexSettledTurnFinalizationContext(params: {
|
||||
for (const mirroredMessage of mirroredThroughBoundary) {
|
||||
const identity = readMirrorIdentity(mirroredMessage);
|
||||
const historyIndex = identity ? historyIdentities.get(identity) : undefined;
|
||||
const historyMessage =
|
||||
historyIndex === undefined ? undefined : params.historyMessages[historyIndex];
|
||||
const historyMessage = historyIndex === undefined ? undefined : historyMessages[historyIndex];
|
||||
if (
|
||||
historyIndex === undefined ||
|
||||
historyIndex <= previousHistoryIndex ||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createCodexTestBindingStore,
|
||||
type CodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
const readCodexAppServerBindingMock = vi.fn();
|
||||
const isCodexAppServerNativeAuthProfileMock = vi.fn();
|
||||
@@ -69,6 +70,7 @@ vi.mock("./shared-client.js", () => ({
|
||||
releaseCodexAppServerClientLease: vi.fn((lease: { client?: unknown }) => {
|
||||
lease.client = undefined;
|
||||
}),
|
||||
retireSharedCodexAppServerClientIfCurrent: vi.fn(),
|
||||
withLeasedCodexAppServerClientStartSelectionRetry: (params: SelectionRetryParams) =>
|
||||
withLeasedCodexAppServerClientStartSelectionRetryMock(params),
|
||||
}));
|
||||
@@ -3077,6 +3079,104 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "after its request is written", written: true, interruptFails: false },
|
||||
{ label: "before its request is written", written: false, interruptFails: false },
|
||||
{
|
||||
label: "when Codex proves its native turn already ended",
|
||||
written: true,
|
||||
interruptFails: false,
|
||||
alreadyTerminal: true,
|
||||
},
|
||||
{
|
||||
label: "when its native thread cannot unsubscribe",
|
||||
written: true,
|
||||
interruptFails: false,
|
||||
unsubscribeFails: true,
|
||||
},
|
||||
{ label: "when its startup interrupt fails", written: true, interruptFails: true },
|
||||
{
|
||||
label: "when its startup interrupt and client retirement fail",
|
||||
written: true,
|
||||
interruptFails: true,
|
||||
retirementFails: true,
|
||||
},
|
||||
])(
|
||||
"scopes side-turn abort cleanup $label",
|
||||
async ({ written, interruptFails, retirementFails, alreadyTerminal, unsubscribeFails }) => {
|
||||
const controller = new AbortController();
|
||||
const harness = createClientHarness();
|
||||
if (retirementFails) {
|
||||
vi.spyOn(harness.client, "closeAndWait").mockRejectedValueOnce(
|
||||
new Error("side client retirement failed"),
|
||||
);
|
||||
}
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(harness.client);
|
||||
const waitForRequest = async (method: string) =>
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const request = harness.writes
|
||||
.map((write) => JSON.parse(write) as { id: number; method: string; params: unknown })
|
||||
.find((message) => message.method === method);
|
||||
if (!request) {
|
||||
throw new Error(`Codex side harness did not write ${method}`);
|
||||
}
|
||||
return request;
|
||||
},
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
);
|
||||
const run = runCodexAppServerSideQuestion(
|
||||
sideParams({ opts: { abortSignal: controller.signal } }),
|
||||
);
|
||||
const failure = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
const fork = await waitForRequest("thread/fork");
|
||||
harness.send({ id: fork.id, result: threadResult("side-thread") });
|
||||
const inject = await waitForRequest("thread/inject_items");
|
||||
harness.send({ id: inject.id, result: {} });
|
||||
|
||||
if (written) {
|
||||
const turnStart = await waitForRequest("turn/start");
|
||||
controller.abort("side-start-cancelled");
|
||||
const interrupt = await waitForRequest("turn/interrupt");
|
||||
expect(interrupt.params).toEqual({ threadId: "side-thread", turnId: "" });
|
||||
harness.send({ id: turnStart.id, result: turnStartResult("turn-1") });
|
||||
harness.send(
|
||||
alreadyTerminal
|
||||
? {
|
||||
id: interrupt.id,
|
||||
error: { code: -32_600, message: "no active turn to interrupt" },
|
||||
}
|
||||
: interruptFails
|
||||
? { id: interrupt.id, error: { code: -32_000, message: "side interrupt failed" } }
|
||||
: { id: interrupt.id, result: {} },
|
||||
);
|
||||
} else {
|
||||
controller.abort("side-start-cancelled");
|
||||
}
|
||||
|
||||
if (!interruptFails) {
|
||||
const unsubscribe = await waitForRequest("thread/unsubscribe");
|
||||
harness.send(
|
||||
unsubscribeFails
|
||||
? { id: unsubscribe.id, error: { code: -32_000, message: "side unsubscribe failed" } }
|
||||
: { id: unsubscribe.id, result: {} },
|
||||
);
|
||||
}
|
||||
await expect(failure).resolves.toMatchObject({ message: "turn/start aborted" });
|
||||
expect(harness.writes.map((write) => JSON.parse(write).method)).toEqual([
|
||||
"thread/fork",
|
||||
"thread/inject_items",
|
||||
...(written ? ["turn/start", "turn/interrupt"] : []),
|
||||
...(!interruptFails ? ["thread/unsubscribe"] : []),
|
||||
]);
|
||||
expect(harness.stdinDestroyed).toBe(interruptFails || unsubscribeFails === true);
|
||||
harness.client.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("interrupts and unsubscribes the ephemeral thread on abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createFakeClient();
|
||||
|
||||
@@ -20,13 +20,22 @@ import {
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
|
||||
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
import {
|
||||
isCodexAlreadyTerminalInterruptError,
|
||||
retireUnsafeCodexTurnClientBestEffort,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { resolveCodexAppServerPreparedAuthHandoff } from "./auth-bridge.js";
|
||||
import {
|
||||
requireCodexSupervisionModelSelection,
|
||||
resolveCodexBindingAppServerConnection,
|
||||
} from "./binding-connection.js";
|
||||
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
|
||||
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
isCodexAppServerApprovalRequest,
|
||||
isCodexAppServerIndeterminateRequestCancellationError,
|
||||
type CodexAppServerClient,
|
||||
} from "./client.js";
|
||||
import {
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
readCodexPluginConfig,
|
||||
@@ -659,31 +668,39 @@ export async function runCodexAppServerSideQuestion(
|
||||
readCodexSupportedReasoningEfforts(params.runtimeModel?.compat),
|
||||
);
|
||||
const turnResponse = assertCodexTurnStartResponse(
|
||||
await client.request(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: childThreadId,
|
||||
input: [{ type: "text", text: params.question.trim(), text_elements: [] }],
|
||||
cwd,
|
||||
model: modelSelection.model,
|
||||
...(usesSupervisionConnection ? {} : { personality: CODEX_NATIVE_PERSONALITY_NONE }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...(usesSupervisionConnection
|
||||
? {}
|
||||
: {
|
||||
effort,
|
||||
collaborationMode: {
|
||||
mode: "default" as const,
|
||||
settings: {
|
||||
model: modelSelection.model,
|
||||
reasoning_effort: effort,
|
||||
developer_instructions: null,
|
||||
await client
|
||||
.request(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: childThreadId,
|
||||
input: [{ type: "text", text: params.question.trim(), text_elements: [] }],
|
||||
cwd,
|
||||
model: modelSelection.model,
|
||||
...(usesSupervisionConnection ? {} : { personality: CODEX_NATIVE_PERSONALITY_NONE }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...(usesSupervisionConnection
|
||||
? {}
|
||||
: {
|
||||
effort,
|
||||
collaborationMode: {
|
||||
mode: "default" as const,
|
||||
settings: {
|
||||
model: modelSelection.model,
|
||||
reasoning_effort: effort,
|
||||
developer_instructions: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal },
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal },
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (isCodexAppServerIndeterminateRequestCancellationError(error)) {
|
||||
// Codex serializes an empty-id startup interrupt after this written turn/start.
|
||||
turnId = "";
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
turnId = turnResponse.turn.id;
|
||||
collector.setTurn(childThreadId, turnId);
|
||||
@@ -1119,7 +1136,7 @@ async function cleanupCodexSideThread(
|
||||
if (!params.threadId) {
|
||||
return;
|
||||
}
|
||||
if (params.interrupt && params.turnId) {
|
||||
if (params.interrupt && params.turnId !== undefined) {
|
||||
try {
|
||||
await client.request(
|
||||
"turn/interrupt",
|
||||
@@ -1127,17 +1144,21 @@ async function cleanupCodexSideThread(
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex /btw side thread interrupt cleanup failed", { error });
|
||||
if (!isCodexAlreadyTerminalInterruptError(error)) {
|
||||
embeddedAgentLog.debug("codex /btw side thread interrupt cleanup failed", { error });
|
||||
await retireUnsafeCodexTurnClientBestEffort(client, "side turn interrupt");
|
||||
// An unconfirmed native turn must never lose its only visible subscription.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.request(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: params.threadId },
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex /btw side thread unsubscribe cleanup failed", { error });
|
||||
if (
|
||||
!(await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId: params.threadId,
|
||||
timeoutMs: params.timeoutMs,
|
||||
}))
|
||||
) {
|
||||
await retireUnsafeCodexTurnClientBestEffort(client, "side thread unsubscribe");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ describe("Codex ring-zero thread config", () => {
|
||||
expect(start.environments).toEqual([]);
|
||||
expect(start.baseInstructions).toBe("");
|
||||
for (const config of [start.config, resume.config]) {
|
||||
expect(config?.["agents.enabled"]).toBe(false);
|
||||
expect(config?.["tools.experimental_request_user_input.enabled"]).toBe(false);
|
||||
expect(config?.["features.multi_agent"]).toBe(false);
|
||||
expect(config?.["features.multi_agent_v2"]).toBe(false);
|
||||
@@ -134,6 +135,7 @@ describe("Codex delegation capability", () => {
|
||||
});
|
||||
|
||||
for (const request of [start, resume]) {
|
||||
expect(request.config?.["agents.enabled"]).toBe(false);
|
||||
expect(request.config?.["features.multi_agent"]).toBe(false);
|
||||
expect(request.config?.["features.multi_agent_v2"]).toBe(false);
|
||||
expect(request.config?.["features.goals"]).toBe(false);
|
||||
@@ -190,6 +192,7 @@ describe("Codex delegation capability", () => {
|
||||
|
||||
for (const request of [start, resume]) {
|
||||
for (const disabledFeature of [
|
||||
"agents.enabled",
|
||||
"features.apps",
|
||||
"features.current_time_reminder",
|
||||
"features.deferred_executor",
|
||||
|
||||
@@ -58,11 +58,13 @@ const CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG: JsonObject = {
|
||||
};
|
||||
|
||||
const CODEX_DELEGATION_DISABLED_THREAD_CONFIG: JsonObject = {
|
||||
"agents.enabled": false,
|
||||
"features.multi_agent": false,
|
||||
"features.multi_agent_v2": false,
|
||||
};
|
||||
|
||||
const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = {
|
||||
...CODEX_DELEGATION_DISABLED_THREAD_CONFIG,
|
||||
"features.apps": false,
|
||||
"features.current_time_reminder": false,
|
||||
"features.deferred_executor": false,
|
||||
@@ -71,8 +73,6 @@ const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = {
|
||||
"features.hooks": false,
|
||||
"features.image_generation": false,
|
||||
"features.memories": false,
|
||||
"features.multi_agent": false,
|
||||
"features.multi_agent_v2": false,
|
||||
"features.plugins": false,
|
||||
"features.standalone_web_search": false,
|
||||
"features.token_budget": false,
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
type CodexAppServerThreadBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.test-helpers.js";
|
||||
import { createClientHarness } from "./app-server/test-support.js";
|
||||
import { getCodexAppServerTurnRouter } from "./app-server/turn-router.js";
|
||||
import { legacyCodexConversationBindingId } from "./conversation-binding-data.js";
|
||||
import { codexConversationBindingRuntime } from "./conversation-binding.js";
|
||||
@@ -2350,6 +2351,89 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "ordinary", sessionKey: undefined, interruptFails: false },
|
||||
{
|
||||
label: "incognito",
|
||||
sessionKey: "agent:main:dashboard:incognito-start-timeout",
|
||||
interruptFails: false,
|
||||
},
|
||||
{ label: "ordinary with a failed interrupt", sessionKey: undefined, interruptFails: true },
|
||||
{
|
||||
label: "incognito with a failed interrupt",
|
||||
sessionKey: "agent:main:dashboard:incognito-start-interrupt-failure",
|
||||
interruptFails: true,
|
||||
},
|
||||
])(
|
||||
"interrupts an indeterminate $label native turn before cleanup",
|
||||
async ({ sessionKey, interruptFails }) => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const harness = createClientHarness();
|
||||
await writeTestConversationBinding(sessionFile, {
|
||||
threadId: "thread-1",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: tempDir,
|
||||
});
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue(harness.client);
|
||||
if (interruptFails) {
|
||||
sharedClientMocks.retireSharedCodexAppServerClientIfCurrent.mockReturnValueOnce({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
}
|
||||
const waitForRequest = async (method: string) =>
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const request = harness.writes
|
||||
.map((write) => JSON.parse(write) as { id: number; method: string; params: unknown })
|
||||
.find((message) => message.method === method);
|
||||
if (!request) {
|
||||
throw new Error(`Codex conversation harness did not write ${method}`);
|
||||
}
|
||||
return request;
|
||||
},
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
);
|
||||
const { event, ctx } = boundConversationClaim(sessionFile, sessionKey);
|
||||
const result = handleCodexConversationInboundClaim(event, ctx, {
|
||||
pluginConfig: { appServer: { requestTimeoutMs: 100 } },
|
||||
});
|
||||
const turnStart = await waitForRequest("turn/start");
|
||||
const interrupt = await waitForRequest("turn/interrupt");
|
||||
expect(interrupt.params).toEqual({ threadId: "thread-1", turnId: "" });
|
||||
harness.send({ id: turnStart.id, result: { turn: { id: "turn-1" } } });
|
||||
harness.send(
|
||||
interruptFails
|
||||
? { id: interrupt.id, error: { code: -32_000, message: "startup interrupt failed" } }
|
||||
: { id: interrupt.id, result: {} },
|
||||
);
|
||||
if (sessionKey && !interruptFails) {
|
||||
const unsubscribe = await waitForRequest("thread/unsubscribe");
|
||||
harness.send({ id: unsubscribe.id, result: {} });
|
||||
}
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
handled: true,
|
||||
reply: { text: "Codex app-server turn failed: turn/start timed out" },
|
||||
});
|
||||
expect(harness.writes.map((write) => JSON.parse(write).method)).toEqual([
|
||||
"turn/start",
|
||||
"turn/interrupt",
|
||||
...(sessionKey && !interruptFails ? ["thread/unsubscribe"] : []),
|
||||
]);
|
||||
expect(sharedClientMocks.retireSharedCodexAppServerClientIfCurrent).toHaveBeenCalledTimes(
|
||||
interruptFails ? 1 : 0,
|
||||
);
|
||||
expect(
|
||||
readCodexConversationActiveTurn(testConversationIdentity(sessionFile)),
|
||||
).toBeUndefined();
|
||||
if (sessionKey) {
|
||||
await expect(readTestConversationBinding(sessionFile)).resolves.toBeUndefined();
|
||||
}
|
||||
harness.client.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "ordinary", sessionKey: undefined },
|
||||
{ label: "incognito", sessionKey: "agent:main:dashboard:incognito-turn-timeout" },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Codex plugin module implements conversation binding behavior.
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
@@ -19,11 +18,15 @@ import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
closeCodexStartupClientBestEffort,
|
||||
interruptCodexTurnAndWaitBestEffort,
|
||||
retireUnsafeCodexTurnClientBestEffort,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./app-server/attempt-client-cleanup.js";
|
||||
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import {
|
||||
isCodexAppServerIndeterminateRequestCancellationError,
|
||||
type CodexAppServerClient,
|
||||
} from "./app-server/client.js";
|
||||
import {
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
codexSandboxPolicyForTurn,
|
||||
@@ -883,19 +886,21 @@ async function runBoundTurn(params: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut = error instanceof CodexConversationTurnTimeoutError;
|
||||
if (timedOut && activeTurnId) {
|
||||
// Keep the exact turn, notification handlers, and lease alive until
|
||||
// Codex confirms the terminal notification; otherwise inference survives.
|
||||
if (
|
||||
(error instanceof CodexConversationTurnTimeoutError && activeTurnId) ||
|
||||
(turnRoute && isCodexAppServerIndeterminateRequestCancellationError(error))
|
||||
) {
|
||||
// Per-thread serialization makes an empty startup interrupt follow an
|
||||
// accepted turn whose id was lost to local request cancellation.
|
||||
const completed = await interruptCodexTurnAndWaitBestEffort(client, {
|
||||
threadId,
|
||||
turnId: activeTurnId,
|
||||
turnId: activeTurnId ?? "",
|
||||
});
|
||||
if (!completed) {
|
||||
// Retirement detaches the physical client while sibling leases finish;
|
||||
// never send another cleanup request or retire that detached client twice.
|
||||
retiredUnsafeClient = client;
|
||||
await retireUnsafeCodexConversationClientBestEffort(client, "turn interrupt");
|
||||
await retireUnsafeCodexTurnClientBestEffort(client, "turn interrupt");
|
||||
}
|
||||
}
|
||||
if (isIncognitoSessionKey(params.sessionKey)) {
|
||||
@@ -909,7 +914,7 @@ async function runBoundTurn(params: {
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!unsubscribed) {
|
||||
await retireUnsafeCodexConversationClientBestEffort(client, "thread unsubscribe");
|
||||
await retireUnsafeCodexTurnClientBestEffort(client, "thread unsubscribe");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,21 +926,6 @@ async function runBoundTurn(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function retireUnsafeCodexConversationClientBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
operation: "turn interrupt" | "thread unsubscribe",
|
||||
): Promise<void> {
|
||||
try {
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
} catch (error) {
|
||||
// Cleanup must not replace the original turn or timeout failure.
|
||||
embeddedAgentLog.debug("codex conversation client retirement failed during cleanup", {
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeConversationApprovalPolicySupported(params: {
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
approvalPolicy: ReturnType<typeof resolveCodexAppServerRuntimeOptions>["approvalPolicy"];
|
||||
|
||||
@@ -242,6 +242,9 @@ describe("codex web search provider", () => {
|
||||
"--listen",
|
||||
"stdio://",
|
||||
"-c",
|
||||
"openai_base_url=http://127.0.0.1:44080/v1",
|
||||
"--config=model_catalog_json=/tmp/qa catalog/models.json",
|
||||
"-c",
|
||||
"mcp_servers.external.command='unsafe'",
|
||||
],
|
||||
clearEnv: ["CODEX_HOME", "KEEP_CLEARED"],
|
||||
@@ -311,7 +314,15 @@ describe("codex web search provider", () => {
|
||||
const threadStartCwd = (requests[1]?.params as { cwd?: string } | undefined)?.cwd;
|
||||
const isolatedCodexHome = isolatedStartOptions?.env?.CODEX_HOME;
|
||||
expect(threadStartCwd).not.toBe("/tmp/openclaw-agent");
|
||||
expect(isolatedStartOptions?.args).toEqual(["app-server", "--listen", "stdio://"]);
|
||||
expect(isolatedStartOptions?.args).toEqual([
|
||||
"app-server",
|
||||
"-c",
|
||||
"openai_base_url=http://127.0.0.1:44080/v1",
|
||||
"-c",
|
||||
"model_catalog_json=/tmp/qa catalog/models.json",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
]);
|
||||
expect(isolatedStartOptions?.clearEnv).toEqual([
|
||||
"KEEP_CLEARED",
|
||||
"OPENCLAW_CODEX_APP_SERVER_ARGS",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* CJK-aware character weighting for Ollama usage fallback estimates.
|
||||
*
|
||||
* This stays plugin-private because exposing it through the Plugin SDK would
|
||||
* create a stable public contract for one provider-specific fallback. Keep
|
||||
* the weighting aligned with normalization-core's CJK budget heuristic.
|
||||
*/
|
||||
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
|
||||
const NON_ASCII_RE = /[\u0080-\u{10FFFF}]/u;
|
||||
const COMMON_CJK_RE = /[\u00B7\u3000-\u319F\u4E00-\u9FA5\uAC00-\uD7AF\uFF01-\uFF60]/gu;
|
||||
const RARE_BMP_CJK_RE =
|
||||
/[\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF]/gu;
|
||||
const TWO_TOKEN_CJK_RE =
|
||||
/[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6]|\u{0305}|\u{0323}/gu;
|
||||
const THREE_TOKEN_SUPPLEMENTARY_CJK_RE = /[\u{1D360}-\u{1D371}]/gu;
|
||||
const SUPPLEMENTARY_CJK_RE =
|
||||
/[\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]/gu;
|
||||
const SPECIAL_CJK_RE =
|
||||
/[\u{02C7}\u{02C9}-\u{02CB}\u{02D9}\u{02EA}-\u{02EB}\u1100-\u11FF\u2E80-\u2FFF\u31A0-\u4DFF\u9FA6-\u9FFF\uA000-\uA4FF\uA700-\uA707\uA960-\uA97F\uD7B0-\uD7FF\uF900-\uFAFF\uFE10-\uFE4F\uFF61-\uFFDC\uFFE0-\uFFE6\u{16FE0}-\u{16FFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B16F}\u{1D360}-\u{1D371}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FA1F}\u{30000}-\u{3347F}]|\u{0305}|\u{0323}/u;
|
||||
|
||||
function countMatches(text: string, pattern: RegExp): number {
|
||||
return (text.match(pattern) ?? []).length;
|
||||
}
|
||||
|
||||
export function estimateStringChars(text: string): number {
|
||||
if (!NON_ASCII_RE.test(text)) {
|
||||
return text.length;
|
||||
}
|
||||
const commonCjkCount = countMatches(text, COMMON_CJK_RE);
|
||||
const commonEstimate = text.length + commonCjkCount * (CHARS_PER_TOKEN_ESTIMATE - 1);
|
||||
if (!SPECIAL_CJK_RE.test(text)) {
|
||||
return commonEstimate;
|
||||
}
|
||||
const rareBmpCjkCount = countMatches(text, RARE_BMP_CJK_RE);
|
||||
const twoTokenCjkCount = countMatches(text, TWO_TOKEN_CJK_RE);
|
||||
const threeTokenSupplementaryCjkCount = countMatches(text, THREE_TOKEN_SUPPLEMENTARY_CJK_RE);
|
||||
const supplementaryCjkCount = countMatches(text, SUPPLEMENTARY_CJK_RE);
|
||||
return (
|
||||
commonEstimate +
|
||||
rareBmpCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 1) +
|
||||
twoTokenCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 2 - 1) +
|
||||
threeTokenSupplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 3 - 2) +
|
||||
supplementaryCjkCount * (CHARS_PER_TOKEN_ESTIMATE * 4 - 2)
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { estimateStringChars } from "./cjk-char-estimate.js";
|
||||
import { estimateStringChars, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
|
||||
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
|
||||
import { normalizeOllamaWireModelId } from "./model-id.js";
|
||||
|
||||
@@ -3624,6 +3624,86 @@ describe("qa mock openai server", () => {
|
||||
expect(await firstB.text()).toContain('\\"label\\":\\"qa-fanout-alpha\\"');
|
||||
});
|
||||
|
||||
it("isolates interleaved session state while preserving cross-provider ownership", async () => {
|
||||
const server = await startMockServer();
|
||||
const handoffPrompt =
|
||||
"Delegate one bounded QA task to a subagent. Wait for the subagent to finish.";
|
||||
const fanoutPrompt =
|
||||
"Subagent fanout synthesis check: delegate two bounded subagents sequentially, then report both results together.";
|
||||
const sessions = ["qa-session-alpha", "qa-session-beta"] as const;
|
||||
const runtimePrompt = (sessionId: string) =>
|
||||
`Runtime: agent=main | sessionId=${sessionId} | channel=qa`;
|
||||
const postSession = (sessionId: string, input: unknown[], cacheBoundary = 0) =>
|
||||
expectNonStreamingResponsesJson(server, {
|
||||
model: "gpt-5.6-luna",
|
||||
prompt_cache_key: `${sessionId}:${cacheBoundary}`,
|
||||
tools: [SESSIONS_SPAWN_TOOL],
|
||||
input: [makeDeveloperInput(runtimePrompt(sessionId)), ...input],
|
||||
});
|
||||
|
||||
const handoffs = await Promise.all(
|
||||
sessions.map((sessionId) => postSession(sessionId, [makeUserInput(handoffPrompt)])),
|
||||
);
|
||||
for (const handoff of handoffs) {
|
||||
expect(outputToolArgsFromItem(outputToolCall(handoff, "sessions_spawn"))).toMatchObject({
|
||||
label: "qa-sidecar",
|
||||
});
|
||||
}
|
||||
|
||||
const crossProviderContinuation = await postJson(server, "/v1/messages", {
|
||||
model: "claude-opus-4-8",
|
||||
max_tokens: 256,
|
||||
system: [{ type: "text", text: runtimePrompt(sessions[0]) }],
|
||||
tools: [{ name: "sessions_spawn", input_schema: { type: "object", properties: {} } }],
|
||||
messages: [makeAnthropicUserText(handoffPrompt)],
|
||||
});
|
||||
expect(crossProviderContinuation.status).toBe(200);
|
||||
const continued = requireRecord(
|
||||
await crossProviderContinuation.json(),
|
||||
"cross-provider handoff continuation",
|
||||
);
|
||||
expect(continued.stop_reason).toBe("end_turn");
|
||||
|
||||
const anthropicHandoff = await postJson(server, "/v1/messages", {
|
||||
model: "claude-opus-4-8",
|
||||
max_tokens: 256,
|
||||
system: [{ type: "text", text: runtimePrompt("qa-session-anthropic") }],
|
||||
tools: [{ name: "sessions_spawn", input_schema: { type: "object", properties: {} } }],
|
||||
messages: [makeAnthropicUserText(handoffPrompt)],
|
||||
});
|
||||
expect(anthropicHandoff.status).toBe(200);
|
||||
expect(
|
||||
requireRecord(await anthropicHandoff.json(), "independent Anthropic handoff"),
|
||||
).toMatchObject({ stop_reason: "tool_use" });
|
||||
|
||||
const firstFanoutCalls = await Promise.all(
|
||||
sessions.map((sessionId) => postSession(sessionId, [makeUserInput(fanoutPrompt)], 1)),
|
||||
);
|
||||
for (const fanout of firstFanoutCalls) {
|
||||
expect(outputToolArgsFromItem(outputToolCall(fanout, "sessions_spawn"))).toMatchObject({
|
||||
label: "qa-fanout-alpha",
|
||||
});
|
||||
}
|
||||
|
||||
const secondFanoutCalls = await Promise.all(
|
||||
sessions.map((sessionId) =>
|
||||
postSession(
|
||||
sessionId,
|
||||
[
|
||||
makeUserInput(fanoutPrompt),
|
||||
makeToolOutput('{"status":"accepted","childSessionKey":"alpha","note":"ALPHA-OK"}'),
|
||||
],
|
||||
2,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const fanout of secondFanoutCalls) {
|
||||
expect(outputToolArgsFromItem(outputToolCall(fanout, "sessions_spawn"))).toMatchObject({
|
||||
label: "qa-fanout-beta",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("answers heartbeat prompts without spawning extra subagents", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listMockOpenAiServerModelIds,
|
||||
} from "../shared/mock-model-config.js";
|
||||
import { buildMessagesPayload } from "./mock-anthropic-messages.js";
|
||||
import { convertAnthropicMessagesToResponsesInput } from "./mock-anthropic-wire.js";
|
||||
import {
|
||||
buildAssistantText,
|
||||
isCanonicalCompactionRetryWriteResult,
|
||||
@@ -1807,12 +1808,32 @@ export async function startQaMockOpenAiServer(params?: {
|
||||
}) {
|
||||
const host = params?.host ?? "127.0.0.1";
|
||||
const finalOnlyMarkerPauseMs = params?.finalOnlyMarkerPauseMs ?? 1_500;
|
||||
const scenarioState: MockScenarioState = {
|
||||
anthropicThinkingErrorScenarioKeys: new Set<string>(),
|
||||
subagentFanoutCompletedWorkers: new Set<"alpha" | "beta">(),
|
||||
subagentFanoutPhase: 0,
|
||||
subagentHandoffSpawned: false,
|
||||
toolLoopReadAttempts: 0,
|
||||
const scenarioStates = new Map<string, MockScenarioState>();
|
||||
const scenarioStateFor = (body: Record<string, unknown>): MockScenarioState => {
|
||||
const input = Array.isArray(body.input)
|
||||
? body.input
|
||||
: convertAnthropicMessagesToResponsesInput({
|
||||
system: body.system as AnthropicMessagesRequest["system"],
|
||||
messages: [],
|
||||
});
|
||||
const systemPrompt = extractAllRequestTexts(
|
||||
input.filter((item) => item.role === "developer" || item.role === "system"),
|
||||
body,
|
||||
);
|
||||
const sessionId =
|
||||
/\bRuntime:\s*[^\n]*\bsessionId=([^\s|]+)/u.exec(systemPrompt)?.[1] ??
|
||||
(body.client_metadata as { session_id?: unknown } | undefined)?.session_id;
|
||||
const key = typeof sessionId === "string" ? sessionId : "";
|
||||
// Runtime session identity survives provider switches and cache-boundary changes.
|
||||
const state = scenarioStates.get(key) ?? {
|
||||
anthropicThinkingErrorScenarioKeys: new Set<string>(),
|
||||
subagentFanoutCompletedWorkers: new Set<"alpha" | "beta">(),
|
||||
subagentFanoutPhase: 0,
|
||||
subagentHandoffSpawned: false,
|
||||
toolLoopReadAttempts: 0,
|
||||
};
|
||||
scenarioStates.set(key, state);
|
||||
return state;
|
||||
};
|
||||
let lastRequest: MockOpenAiRequestSnapshot | null = null;
|
||||
const requests: MockOpenAiRequestSnapshot[] = [];
|
||||
@@ -1845,7 +1866,7 @@ export async function startQaMockOpenAiServer(params?: {
|
||||
inflightRequests.set(inflightRequestId, { prompt, allInputText });
|
||||
let events: StreamEvent[];
|
||||
try {
|
||||
events = await buildResponsesPayload(request.body, scenarioState);
|
||||
events = await buildResponsesPayload(request.body, scenarioStateFor(request.body));
|
||||
} finally {
|
||||
inflightRequests.delete(inflightRequestId);
|
||||
}
|
||||
@@ -2067,6 +2088,7 @@ export async function startQaMockOpenAiServer(params?: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const scenarioState = scenarioStateFor(body as Record<string, unknown>);
|
||||
const {
|
||||
events,
|
||||
input,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "./openai-responses-stream-slots-internal.js";
|
||||
import {
|
||||
createResponsesTerminalController,
|
||||
resolveCompletedToolCallName,
|
||||
resolveCompletedResponsesToolCall,
|
||||
resolveResponsesToolCallId,
|
||||
type ResponsesEventSink,
|
||||
type ResponsesThinkingBlock,
|
||||
@@ -618,7 +618,6 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
if (!streamingToolCall && streamingToolCalls.hasActive()) {
|
||||
continue;
|
||||
}
|
||||
const completedName = resolveCompletedToolCallName(streamingToolCall, item.name);
|
||||
const streamedArguments = streamingToolCall?.block.partialJson ?? "";
|
||||
const completedArguments =
|
||||
typeof item.arguments === "string" ? item.arguments : undefined;
|
||||
@@ -633,8 +632,11 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
completedArguments !== undefined &&
|
||||
(completedArguments.length > 0 || !streamedArguments)
|
||||
? completedArguments
|
||||
: streamedArguments || "{}";
|
||||
const args = parseStreamingJson(finalArguments);
|
||||
: streamedArguments;
|
||||
const validated = resolveCompletedResponsesToolCall(item, {
|
||||
name: streamingToolCall?.block.name,
|
||||
arguments: finalArguments,
|
||||
});
|
||||
|
||||
let toolCall: ToolCall;
|
||||
let contentIndex: number;
|
||||
@@ -644,10 +646,10 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
// the canonical id on completion. Upgrade the same public block so
|
||||
// replay and its function_call_output retain both identities.
|
||||
block.id = resolveResponsesToolCallId(item, block.id);
|
||||
block.name = completedName;
|
||||
block.name = validated.name;
|
||||
// Finalize in-place and strip the scratch buffer so replay only
|
||||
// carries parsed arguments.
|
||||
block.arguments = args;
|
||||
block.arguments = validated.arguments;
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
toolCall = block;
|
||||
contentIndex = streamingToolCall.contentIndex;
|
||||
@@ -655,8 +657,8 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
toolCall = {
|
||||
type: "toolCall",
|
||||
id: resolveResponsesToolCallId(item),
|
||||
name: completedName,
|
||||
arguments: args,
|
||||
name: validated.name,
|
||||
arguments: validated.arguments,
|
||||
};
|
||||
// Some compatible streams only send the completed item. Preserve
|
||||
// the normal balanced lifecycle and persist the call for replay.
|
||||
|
||||
@@ -80,12 +80,15 @@ export function resolveResponsesToolCallId(
|
||||
return resolvedItemId ? `${generated}|${resolvedItemId}` : generated;
|
||||
}
|
||||
|
||||
export function resolveCompletedToolCallName(
|
||||
toolCall: { block: { name: string } } | undefined,
|
||||
value: unknown,
|
||||
): string {
|
||||
const streamedName = toolCall?.block.name.trim() || undefined;
|
||||
const completedName = typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
export function resolveCompletedResponsesToolCall(
|
||||
item: Extract<ResponseOutputItem, { type: "function_call" }>,
|
||||
streamed?: { name?: string; arguments?: string },
|
||||
): Pick<ToolCall, "name" | "arguments"> {
|
||||
if (item.status && item.status !== "completed") {
|
||||
throw new Error("Responses stream completed with an incomplete terminal tool call");
|
||||
}
|
||||
const streamedName = streamed?.name?.trim() || undefined;
|
||||
const completedName = typeof item.name === "string" ? item.name.trim() || undefined : undefined;
|
||||
if (streamedName && completedName && streamedName !== completedName) {
|
||||
throw new Error(
|
||||
`Responses stream changed tool-call function name from ${streamedName} to ${completedName}`,
|
||||
@@ -95,7 +98,13 @@ export function resolveCompletedToolCallName(
|
||||
if (!name) {
|
||||
throw new Error("Responses stream completed tool call without a function name");
|
||||
}
|
||||
return name;
|
||||
const argumentsValue = parseJsonObjectPreservingUnsafeIntegers(
|
||||
streamed?.arguments ?? item.arguments,
|
||||
);
|
||||
if (!argumentsValue) {
|
||||
throw new Error("Responses stream completed tool call with invalid JSON arguments");
|
||||
}
|
||||
return { name, arguments: argumentsValue };
|
||||
}
|
||||
|
||||
export function createResponsesTerminalController(params: {
|
||||
@@ -195,21 +204,8 @@ export function createResponsesTerminalController(params: {
|
||||
stream.push({ type: "text_start", contentIndex: index, partial: output as never });
|
||||
stream.push({ type: "text_end", contentIndex: index, content: text, partial: output as never });
|
||||
};
|
||||
const validateCompletedToolCall = (
|
||||
item: Extract<ResponseOutputItem, { type: "function_call" }>,
|
||||
) => {
|
||||
if (item.status && item.status !== "completed") {
|
||||
throw new Error("Responses stream completed with an incomplete terminal tool call");
|
||||
}
|
||||
const name = resolveCompletedToolCallName(undefined, item.name);
|
||||
const argumentsValue = parseJsonObjectPreservingUnsafeIntegers(item.arguments);
|
||||
if (!argumentsValue) {
|
||||
throw new Error("Responses stream completed tool call with invalid JSON arguments");
|
||||
}
|
||||
return { name, arguments: argumentsValue };
|
||||
};
|
||||
const appendToolCall = (item: Extract<ResponseOutputItem, { type: "function_call" }>) => {
|
||||
const validated = validateCompletedToolCall(item);
|
||||
const validated = resolveCompletedResponsesToolCall(item);
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: resolveResponsesToolCallId(item),
|
||||
@@ -249,7 +245,7 @@ export function createResponsesTerminalController(params: {
|
||||
throw new Error("Responses stream omitted an output item before completed output");
|
||||
}
|
||||
if (item.type === "function_call") {
|
||||
validateCompletedToolCall(item);
|
||||
resolveCompletedResponsesToolCall(item);
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
|
||||
@@ -85,6 +85,127 @@ const rejectedTerminalToolBatchFixture = (params: {
|
||||
},
|
||||
});
|
||||
|
||||
const rejectedStreamedToolFixture = (params: {
|
||||
name: string;
|
||||
status: "completed" | "incomplete";
|
||||
arguments?: string;
|
||||
error: string;
|
||||
started?: boolean;
|
||||
}): ParityFixture => {
|
||||
const item = {
|
||||
id: "fc_rejected_streamed",
|
||||
call_id: "call_rejected_streamed",
|
||||
type: "function_call",
|
||||
name: "lookup",
|
||||
...(params.arguments === undefined ? {} : { arguments: params.arguments }),
|
||||
status: params.status,
|
||||
};
|
||||
const started = params.started
|
||||
? [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { ...item, arguments: "", status: "in_progress" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
item_id: item.id,
|
||||
delta: "{",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
name: params.name,
|
||||
events: [
|
||||
...started,
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
completed("resp_rejected_streamed_tool", [item]),
|
||||
],
|
||||
canonical: {
|
||||
events: params.started
|
||||
? [
|
||||
{ type: "toolcall_start", contentIndex: 0 },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: "{" },
|
||||
]
|
||||
: [],
|
||||
content: params.started
|
||||
? [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_rejected_streamed|fc_rejected_streamed",
|
||||
name: "lookup",
|
||||
arguments: {},
|
||||
partialJson: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
responseId: null,
|
||||
stopReason: "stop",
|
||||
error: params.error,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const unsafeIntegerToolFixture = (source: "streamed" | "terminal"): ParityFixture => {
|
||||
const item = {
|
||||
id: "fc_unsafe_integer",
|
||||
call_id: "call_unsafe_integer",
|
||||
type: "function_call",
|
||||
name: "send_message",
|
||||
arguments:
|
||||
'{"to":1481220477346119781,"safe":42,"maxSafe":9007199254740991,"nested":{"ids":[9007199254740993,-9007199254740992]}}',
|
||||
status: "completed",
|
||||
};
|
||||
const streamed =
|
||||
source === "streamed"
|
||||
? [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { ...item, arguments: "", status: "in_progress" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
item_id: item.id,
|
||||
delta: '{"to":',
|
||||
},
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
name: `${source} completed tool arguments preserve unsafe integers`,
|
||||
events: [...streamed, completed("resp_unsafe_integer_tool", [item])],
|
||||
canonical: {
|
||||
events: [
|
||||
{ type: "toolcall_start", contentIndex: 0 },
|
||||
...(source === "streamed"
|
||||
? [{ type: "toolcall_delta", contentIndex: 0, delta: '{"to":' }]
|
||||
: []),
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_unsafe_integer|fc_unsafe_integer",
|
||||
name: "send_message",
|
||||
arguments: {
|
||||
to: "1481220477346119781",
|
||||
safe: 42,
|
||||
maxSafe: 9007199254740991,
|
||||
nested: { ids: ["9007199254740993", "-9007199254740992"] },
|
||||
},
|
||||
partialJson: false,
|
||||
},
|
||||
],
|
||||
responseId: "resp_unsafe_integer_tool",
|
||||
stopReason: "toolUse",
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const rejectedOutOfOrderTerminalFixture = (
|
||||
state: "completed" | "started" | "reasoning",
|
||||
): ParityFixture => {
|
||||
@@ -342,6 +463,32 @@ const fixtures: ParityFixture[] = [
|
||||
arguments: '{"q":',
|
||||
error: "Responses stream completed tool call with invalid JSON arguments",
|
||||
}),
|
||||
rejectedStreamedToolFixture({
|
||||
name: "streamed malformed tool arguments never complete their started call",
|
||||
status: "completed",
|
||||
arguments: '{"q":',
|
||||
error: "Responses stream completed tool call with invalid JSON arguments",
|
||||
started: true,
|
||||
}),
|
||||
rejectedStreamedToolFixture({
|
||||
name: "streamed non-object tool arguments never start a call",
|
||||
status: "completed",
|
||||
arguments: "[]",
|
||||
error: "Responses stream completed tool call with invalid JSON arguments",
|
||||
}),
|
||||
rejectedStreamedToolFixture({
|
||||
name: "streamed missing tool arguments never fabricate an empty call",
|
||||
status: "completed",
|
||||
error: "Responses stream completed tool call with invalid JSON arguments",
|
||||
}),
|
||||
rejectedStreamedToolFixture({
|
||||
name: "streamed incomplete tool calls never start a call",
|
||||
status: "incomplete",
|
||||
arguments: '{"q":"x"}',
|
||||
error: "Responses stream completed with an incomplete terminal tool call",
|
||||
}),
|
||||
unsafeIntegerToolFixture("streamed"),
|
||||
unsafeIntegerToolFixture("terminal"),
|
||||
rejectedTerminalToolBatchFixture({
|
||||
name: "terminal tool batch rejects a later incomplete call before executing earlier calls",
|
||||
status: "incomplete",
|
||||
|
||||
@@ -13,6 +13,7 @@ scenario:
|
||||
objective: Verify compaction does not remove active session-goal context from the next model turn.
|
||||
successCriteria:
|
||||
- An authorized channel turn starts a session goal through `/goal start`.
|
||||
- A separate conversation completes a turn before the original session is compacted.
|
||||
- The same session completes a manual `/compact` operation.
|
||||
- The first post-compaction inbound turn reaches the mock model with the exact active-goal context line.
|
||||
- The assertion uses the captured post-compaction provider request.
|
||||
@@ -39,6 +40,7 @@ scenario:
|
||||
goalObjective: Preserve the deployment audit objective
|
||||
expectedGoalContext: "Active goal: Preserve the deployment audit objective — advance; keep active until fully achieved; block only after the same blocker on 3 consecutive turns; after update_goal, provide the requested visible final."
|
||||
contextSeedMarker: GOAL-COMPACTION-CONTEXT-SEED
|
||||
otherConversationMarker: GOAL-COMPACTION-SECOND-SESSION-READY
|
||||
postCompactionMessage: GOAL-CONTEXT-POST-COMPACTION unrelated lunch question
|
||||
|
||||
flow:
|
||||
@@ -104,6 +106,46 @@ flow:
|
||||
ref: seedOutboundIndex
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- set: otherConversationId
|
||||
value:
|
||||
expr: "`${conversationId}-other`"
|
||||
- set: otherDelivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: `group:${otherConversationId}` })"
|
||||
- set: otherSessionKey
|
||||
value:
|
||||
expr: "buildAgentSessionKey({ agentId: 'qa', channel: otherDelivery.channel, accountId: transport.accountId, peer: { kind: 'group', id: otherDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
|
||||
- set: otherOutboundIndex
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
|
||||
- sendInbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: otherConversationId
|
||||
kind: group
|
||||
senderId:
|
||||
ref: config.senderId
|
||||
senderName: QA Goal Operator
|
||||
text:
|
||||
expr: "`@openclaw reply exactly: ${config.otherConversationMarker}`"
|
||||
- waitForOutbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: otherConversationId
|
||||
kind: group
|
||||
sinceIndex:
|
||||
ref: otherOutboundIndex
|
||||
textIncludes:
|
||||
ref: config.otherConversationMarker
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- call: waitForCondition
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: "env.gateway.call('sessions.list', {}, { timeoutMs: 30000 }).then((result) => result.sessions?.find((session) => session.key === otherSessionKey && session.hasActiveRun === false))"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- 100
|
||||
- set: compactOutboundIndex
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
|
||||
|
||||
@@ -279,13 +279,16 @@ describe("before_tool_call loop detection behavior", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function createGenericReadRepeatFixture() {
|
||||
function createGenericReadRepeatFixture(
|
||||
loopDetectionContext?: Parameters<typeof createWrappedTool>[2],
|
||||
) {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: [{ type: "text", text: "same output" }],
|
||||
details: { ok: true },
|
||||
});
|
||||
return {
|
||||
tool: createWrappedTool("read", execute),
|
||||
tool: createWrappedTool("read", execute, loopDetectionContext),
|
||||
execute,
|
||||
params: { path: "/tmp/file" },
|
||||
};
|
||||
}
|
||||
@@ -961,12 +964,29 @@ describe("before_tool_call loop detection behavior", () => {
|
||||
|
||||
it("escalates repeated critical vetoes to the global circuit breaker", async () => {
|
||||
await withToolLoopEvents(async (emitted) => {
|
||||
const { tool, params } = createGenericReadRepeatFixture();
|
||||
const runId = "codex-native-global-breaker";
|
||||
const { tool, params, execute } = createGenericReadRepeatFixture({
|
||||
...enabledLoopDetectionContext,
|
||||
runId,
|
||||
});
|
||||
|
||||
for (let i = 0; i <= 30; i += 1) {
|
||||
await tool.execute(`read-global-${i}`, params, undefined, undefined);
|
||||
for (let i = 0; i <= GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) {
|
||||
const toolCallId = `read-global-${i}`;
|
||||
const nativeOutcome = await runBeforeToolCallHook({
|
||||
toolName: "read",
|
||||
params,
|
||||
toolCallId,
|
||||
ctx: {
|
||||
agentId: enabledLoopDetectionContext.agentId,
|
||||
sessionKey: enabledLoopDetectionContext.sessionKey,
|
||||
runId,
|
||||
},
|
||||
});
|
||||
expect(nativeOutcome.blocked).toBe(false);
|
||||
await tool.execute(toolCallId, params, undefined, undefined);
|
||||
}
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(CRITICAL_THRESHOLD);
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
type: "tool.loop",
|
||||
level: "critical",
|
||||
@@ -1188,6 +1208,7 @@ describe("before_tool_call loop detection behavior", () => {
|
||||
agentId: "main",
|
||||
sessionKey: "session-key",
|
||||
runId: "run-1",
|
||||
loopDetection: { enabled: true },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function runBeforeToolCallHook(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.ctx.loopDetection?.enabled !== false) {
|
||||
if (args.ctx.loopDetection?.enabled === true) {
|
||||
recordToolCall(
|
||||
sessionState,
|
||||
toolName,
|
||||
|
||||
@@ -95,7 +95,7 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
|
||||
kind: "hot",
|
||||
actions: ["restart-heartbeat"],
|
||||
},
|
||||
{ prefix: "agents.defaults.compaction", kind: "hot" },
|
||||
{ prefix: "agents.defaults", kind: "hot" },
|
||||
{
|
||||
prefix: "agents.defaults.models",
|
||||
kind: "hot",
|
||||
@@ -137,7 +137,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
|
||||
{ prefix: "wizard", kind: "none" },
|
||||
{ prefix: "logging", kind: "none" },
|
||||
{ prefix: "agents", kind: "none" },
|
||||
{ prefix: "tools", kind: "none" },
|
||||
{ prefix: "tools", kind: "hot" },
|
||||
{ prefix: "bindings", kind: "none" },
|
||||
{ prefix: "audio", kind: "none" },
|
||||
{ prefix: "agent", kind: "none" },
|
||||
|
||||
@@ -334,6 +334,10 @@ describe("buildGatewayReloadPlan", () => {
|
||||
path: "agents.defaults.models",
|
||||
expected: { restartHeartbeat: true },
|
||||
},
|
||||
{
|
||||
path: "agents.defaults.heartbeat.every",
|
||||
expected: { restartHeartbeat: true },
|
||||
},
|
||||
{
|
||||
path: "agents.defaults.modelPolicy.allow",
|
||||
expected: { restartHeartbeat: true },
|
||||
@@ -359,11 +363,22 @@ describe("buildGatewayReloadPlan", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
"agents.defaults",
|
||||
"agents.defaults.compaction",
|
||||
"agents.defaults.compaction.model",
|
||||
"agents.defaults.compaction.maxActiveTranscriptBytes",
|
||||
"agents.defaults.compaction.memoryFlush.model",
|
||||
])("refreshes compaction config without restarting subsystems: %s", (path) => {
|
||||
"agents.defaults.contextTokens",
|
||||
"agents.defaults.contextPruning.mode",
|
||||
"agents.defaults.contextLimits.postCompactionMaxChars",
|
||||
"agents.defaults.timeoutSeconds",
|
||||
"agents.defaults.userTimezone",
|
||||
"tools",
|
||||
"tools.deny",
|
||||
"tools.allow",
|
||||
"tools.profile",
|
||||
"tools.byProvider.openai.deny",
|
||||
])("refreshes prepared model runtime policy without restarting subsystems: %s", (path) => {
|
||||
const plan = buildGatewayReloadPlan([path]);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
@@ -1741,7 +1756,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
} satisfies OpenClawConfig;
|
||||
const terminalPolicy = createTerminalLaunchPolicy(initialConfig);
|
||||
const events: string[] = [];
|
||||
const onNoopConfigCommit = async (
|
||||
const onHotReload = async (
|
||||
plan: GatewayReloadPlan,
|
||||
nextConfig: OpenClawConfig,
|
||||
ownership: GatewayConfigReloadTransactionOwnership,
|
||||
@@ -1763,7 +1778,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
const harness = createReloaderHarness(vi.fn(), {
|
||||
initialConfig,
|
||||
initialCompareConfig: initialConfig,
|
||||
onNoopConfigCommit,
|
||||
onHotReload,
|
||||
onConfigApplied: () => {
|
||||
events.push("applied");
|
||||
terminalPolicy.commitConfig();
|
||||
@@ -1811,7 +1826,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
agents: { defaults: { sandbox: { mode: "all" as const } } },
|
||||
} satisfies OpenClawConfig;
|
||||
const terminalPolicy = createTerminalLaunchPolicy(initialConfig);
|
||||
const onNoopConfigCommit = async (
|
||||
const onHotReload = async (
|
||||
plan: GatewayReloadPlan,
|
||||
nextConfig: OpenClawConfig,
|
||||
ownership: GatewayConfigReloadTransactionOwnership,
|
||||
@@ -1825,7 +1840,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
{
|
||||
initialConfig,
|
||||
initialCompareConfig: initialConfig,
|
||||
onNoopConfigCommit,
|
||||
onHotReload,
|
||||
onConfigApplied: () => terminalPolicy.commitConfig(),
|
||||
},
|
||||
);
|
||||
@@ -1867,7 +1882,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
} satisfies OpenClawConfig;
|
||||
const terminalPolicy = createTerminalLaunchPolicy(initialConfig);
|
||||
const events: string[] = [];
|
||||
const onNoopConfigCommit = async (
|
||||
const onHotReload = async (
|
||||
plan: GatewayReloadPlan,
|
||||
nextConfig: OpenClawConfig,
|
||||
ownership: GatewayConfigReloadTransactionOwnership,
|
||||
@@ -1888,7 +1903,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
const harness = createReloaderHarness(vi.fn(), {
|
||||
initialConfig,
|
||||
initialCompareConfig: initialConfig,
|
||||
onNoopConfigCommit,
|
||||
onHotReload,
|
||||
onConfigApplied: () => {
|
||||
events.push("applied");
|
||||
terminalPolicy.commitConfig();
|
||||
@@ -2460,7 +2475,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
expect(stopResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("notifies lifecycle owners for no-op sandbox policy changes", async () => {
|
||||
it("hot-reloads sandbox policy for prepared model lifecycle owners", async () => {
|
||||
const initialConfig: OpenClawConfig = {
|
||||
gateway: { reload: {} },
|
||||
agents: { defaults: { sandbox: { mode: "off" } } },
|
||||
@@ -2475,12 +2490,12 @@ describe("startGatewayConfigReloader", () => {
|
||||
await flushWatcherChange(harness);
|
||||
|
||||
expect(harness.onConfigChange).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onConfigChange.mock.calls[0]?.[0].noopPaths).toContain(
|
||||
expect(harness.onConfigChange.mock.calls[0]?.[0].hotReasons).toContain(
|
||||
"agents.defaults.sandbox.mode",
|
||||
);
|
||||
expect(harness.onConfigChange.mock.calls[0]?.[1]).toBe(nextConfig);
|
||||
expect(harness.onConfigApplied).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onHotReload).not.toHaveBeenCalled();
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onRestart).not.toHaveBeenCalled();
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("gateway compaction hot reload", () => {
|
||||
afterEach(resetGatewayState);
|
||||
|
||||
it(
|
||||
"applies a hot-reloaded compaction model to automatic chat preflight without restarting",
|
||||
"applies hot-reloaded tool policy, context budgets, and compaction models without restarting",
|
||||
{ timeout: 90_000 },
|
||||
async () => {
|
||||
const envSnapshot = captureEnv([...ISOLATED_GATEWAY_ENV_KEYS]);
|
||||
@@ -96,6 +96,7 @@ describe("gateway compaction hot reload", () => {
|
||||
deleteTestEnvValue("OPENCLAW_TEST_MINIMAL_GATEWAY");
|
||||
|
||||
const providerRequests: string[] = [];
|
||||
const providerRequestToolNames: string[][] = [];
|
||||
const summaryByModel = new Map<string, string>();
|
||||
const providerServer = createServer((request, response) => {
|
||||
void (async () => {
|
||||
@@ -107,9 +108,15 @@ describe("gateway compaction hot reload", () => {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { model?: string };
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
|
||||
model?: string;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
};
|
||||
const model = body.model ?? "";
|
||||
providerRequests.push(model);
|
||||
providerRequestToolNames.push(
|
||||
body.tools?.map((tool) => tool.name ?? tool.function?.name ?? "") ?? [],
|
||||
);
|
||||
const text = summaryByModel.get(model) ?? "Primary assistant answer.";
|
||||
const message = {
|
||||
type: "message",
|
||||
@@ -269,14 +276,39 @@ describe("gateway compaction hot reload", () => {
|
||||
|
||||
await sendChatAndWait("Warm the existing prepared model runtime.");
|
||||
expect(providerRequests).toContain(primaryModel.modelId);
|
||||
expect(providerRequestToolNames.at(-1)).toContain("exec");
|
||||
expect(loadSessionEntry(scope)?.compactionCount ?? 0).toBe(0);
|
||||
|
||||
const reloadedTools = { deny: ["exec"] };
|
||||
await writeConfigFile({ ...initialConfig, tools: reloadedTools });
|
||||
await expect
|
||||
.poll(() => getRuntimeConfig().tools?.deny, { timeout: 5_000, interval: 50 })
|
||||
.toEqual(["exec"]);
|
||||
await sendChatAndWait("Apply the hot-reloaded tool deny policy to the existing runtime.");
|
||||
expect(providerRequestToolNames.at(-1)).not.toContain("exec");
|
||||
|
||||
const contextTokens = 48_000;
|
||||
const reloadedAgentDefaults = { ...initialConfig.agents.defaults, contextTokens };
|
||||
await writeConfigFile({
|
||||
...initialConfig,
|
||||
agents: { ...initialConfig.agents, defaults: reloadedAgentDefaults },
|
||||
tools: reloadedTools,
|
||||
});
|
||||
await expect
|
||||
.poll(() => getRuntimeConfig().agents?.defaults?.contextTokens, {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
})
|
||||
.toBe(contextTokens);
|
||||
await sendChatAndWait("Apply the hot-reloaded context budget to the existing runtime.");
|
||||
expect(loadSessionEntry(scope)?.contextTokens).toBe(contextTokens);
|
||||
|
||||
await writeConfigFile({
|
||||
...initialConfig,
|
||||
agents: {
|
||||
...initialConfig.agents,
|
||||
defaults: {
|
||||
...initialConfig.agents.defaults,
|
||||
...reloadedAgentDefaults,
|
||||
compaction: {
|
||||
...initialConfig.agents.defaults.compaction,
|
||||
model: newCompactionModel.modelRef,
|
||||
@@ -284,6 +316,7 @@ describe("gateway compaction hot reload", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: reloadedTools,
|
||||
});
|
||||
await expect
|
||||
.poll(() => getRuntimeConfig().agents?.defaults?.compaction, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
validateSessionsCompactParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
|
||||
import { resolveEmbeddedSessionLane } from "../../agents/embedded-agent-runner/lanes.js";
|
||||
import { hasPendingFollowupQueueWork } from "../../auto-reply/reply/queue/state.js";
|
||||
import {
|
||||
resolveSessionWorkStartError,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
trimSessionTranscriptForManualCompact,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { getCommandLaneSnapshot } from "../../process/command-queue.js";
|
||||
import {
|
||||
isCompetingSessionWorkAdmissionActive,
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
@@ -160,17 +161,12 @@ export const sessionCompactHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const lifecycleRevision = entry.lifecycleRevision;
|
||||
const lifecycleIdentities = [
|
||||
key,
|
||||
target.canonicalKey,
|
||||
compactTarget.primaryKey,
|
||||
sessionId,
|
||||
lifecycleRevision,
|
||||
];
|
||||
const queueIdentities = [key, target.canonicalKey, compactTarget.primaryKey, sessionId];
|
||||
const lifecycleIdentities = [...queueIdentities, lifecycleRevision];
|
||||
let sessionStillCurrent = true;
|
||||
let compactionNoopReason: string | undefined;
|
||||
let blockedByActiveRun = false;
|
||||
let blockedByQueuedFollowup = false;
|
||||
let blockedByQueuedWork = false;
|
||||
try {
|
||||
await runExclusiveSessionLifecycleMutation({
|
||||
scope: storePath,
|
||||
@@ -221,18 +217,14 @@ export const sessionCompactHandlers: GatewayRequestHandlers = {
|
||||
agentId: requestedAgentId,
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
});
|
||||
blockedByQueuedFollowup = hasPendingFollowupQueueWork([
|
||||
key,
|
||||
target.canonicalKey,
|
||||
compactTarget.primaryKey,
|
||||
sessionId,
|
||||
]);
|
||||
if (blockedByActiveRun || blockedByQueuedFollowup) {
|
||||
return;
|
||||
}
|
||||
// Queued follow-ups are accepted user intent, not stale transcript work.
|
||||
// Refuse compaction until the queue drains so cleanup cannot erase them.
|
||||
clearSessionQueues([key, target.canonicalKey, compactTarget.primaryKey, sessionId]);
|
||||
// Accepted work can live only in its command lane; waiting behind it
|
||||
// while holding the lifecycle fence would deadlock or drop that turn.
|
||||
blockedByQueuedWork =
|
||||
hasPendingFollowupQueueWork(queueIdentities) ||
|
||||
queueIdentities.some(
|
||||
(identity) =>
|
||||
getCommandLaneSnapshot(resolveEmbeddedSessionLane(identity)).queuedCount > 0,
|
||||
);
|
||||
},
|
||||
run: async () => {
|
||||
if (!sessionStillCurrent) {
|
||||
@@ -260,7 +252,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (blockedByQueuedFollowup) {
|
||||
if (blockedByQueuedWork) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
|
||||
@@ -1441,7 +1441,10 @@ test("sessions.compact preserves accepted queued follow-up work", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.compact preserves an in-flight collected follow-up waiting in the command lane", async () => {
|
||||
test.each([
|
||||
{ name: "follow-up-backed", withFollowup: true },
|
||||
{ name: "lane-only", withFollowup: false },
|
||||
])("sessions.compact preserves accepted $name command-lane work", async ({ withFollowup }) => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const sessionId = "sess-compact-command-queue";
|
||||
const sessionKey = "agent:main:main";
|
||||
@@ -1462,8 +1465,9 @@ test("sessions.compact preserves an in-flight collected follow-up waiting in the
|
||||
enqueuedAt: Date.now(),
|
||||
run: {},
|
||||
} as unknown as FollowupRun;
|
||||
const queue = getFollowupQueue(sessionKey, { mode: "collect" });
|
||||
queue.inFlight.add(queuedRun);
|
||||
if (withFollowup) {
|
||||
getFollowupQueue(sessionKey, { mode: "collect" }).inFlight.add(queuedRun);
|
||||
}
|
||||
setCommandLaneConcurrency(lane, 0);
|
||||
let commandRan = false;
|
||||
const queuedCommand = enqueueCommandInLane(lane, async () => {
|
||||
@@ -1484,7 +1488,9 @@ test("sessions.compact preserves an in-flight collected follow-up waiting in the
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Session main has queued work; retry after it finishes.",
|
||||
});
|
||||
expect(getExistingFollowupQueue(sessionKey)?.inFlight).toContain(queuedRun);
|
||||
expect(Boolean(getExistingFollowupQueue(sessionKey)?.inFlight.has(queuedRun))).toBe(
|
||||
withFollowup,
|
||||
);
|
||||
expect(getCommandLaneSnapshot(lane).queuedCount).toBe(1);
|
||||
expect(commandRan).toBe(false);
|
||||
expect(embeddedRunMock.compactEmbeddedAgentSession).not.toHaveBeenCalled();
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
import type { BaseProbeResult } from "../channels/plugins/types.public.js";
|
||||
import { withTimeout } from "../utils/with-timeout.js";
|
||||
|
||||
export {
|
||||
estimateToolResultTextChars,
|
||||
sliceToolResultTextToBudget,
|
||||
} from "../agents/embedded-agent-runner/tool-result-text-budget.js";
|
||||
export {
|
||||
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
|
||||
resolveLiveToolResultMaxChars,
|
||||
} from "../agents/tool-result-limits.js";
|
||||
export { escapeHtml } from "../shared/html-escape.js";
|
||||
export { estimateStringChars } from "../utils/cjk-chars.js";
|
||||
|
||||
type ChannelProbeResult = BaseProbeResult & { elapsedMs?: number };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user