mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(codex): support managed runtime 0.149 (#127026)
* fix(codex): align managed runtime with 0.149 * test(codex): cover newer prerelease admission * fix(codex): durably queue same-channel async replies * fix(codex): preserve account for durable async replies * chore(codex): document async assertion safety * fix(codex): avoid transcript assertion casts * fix(codex): restore 0.149 type contracts * test(codex): unblock beta validation gates * fix(codex): harden async delivery and release validation Preserve Codex async metadata through an explicit typed owner bridge, defer routed threading work until a delivery is actually routed, and replace brittle whole-branch attestations with a real Guardian browser regression. Co-authored-by: Vincent Koc <vincentkoc@ieee.org> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -69,6 +69,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Codex async delivery:** put same-channel async agent messages into durable outbound custody before acknowledging them, preserving stable replay deduplication across restarts.
|
||||
- **Codex managed runtime:** update the official Codex plugin to app-server 0.149.0 so prerelease publication keeps its required latest-dependency guarantee.
|
||||
- **Codex CLI session resume security:** terminate option parsing before the externally supplied session id so option-shaped identifiers cannot enable Codex CLI flags.
|
||||
- **Onboarding model browsing:** keep preferred-provider model discovery scoped to the selected provider, preserve route variants, and avoid loading unrelated provider setup surfaces. Fixes #125363. Thanks @shakkernerd.
|
||||
- **Codex subagent fan-out:** settle successful terminal yields immediately and preserve requester ownership so completed children reliably resume their parent.
|
||||
|
||||
@@ -212,7 +212,7 @@ extensions/codex/src/app-server/plugin-activation.ts 6
|
||||
extensions/codex/src/app-server/plugin-approval-roundtrip.ts 1
|
||||
extensions/codex/src/app-server/plugin-inventory.ts 6
|
||||
extensions/codex/src/app-server/plugin-metadata-cache.ts 5
|
||||
extensions/codex/src/app-server/plugin-thread-app-admission.ts 2
|
||||
extensions/codex/src/app-server/plugin-thread-app-admission.ts 1
|
||||
extensions/codex/src/app-server/protocol-validators.ts 9
|
||||
extensions/codex/src/app-server/rate-limits.ts 1
|
||||
extensions/codex/src/app-server/reasoning-effort.ts 1
|
||||
|
||||
@@ -245,7 +245,7 @@ reconciliation so OpenClaw does not override that selection.
|
||||
## Remote marketplaces
|
||||
|
||||
Remote marketplace support was introduced in Codex 0.146.1 and remains
|
||||
available in OpenClaw's pinned Codex 0.147.0. OpenClaw passes the opaque remote
|
||||
available in OpenClaw's pinned Codex 0.149.0. OpenClaw passes the opaque remote
|
||||
plugin ID returned by Codex to `plugin/read` and `plugin/install`; a
|
||||
human-readable plugin name is not a valid substitute.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ flags, and plugin allow/deny references into this block. Explicit canonical
|
||||
## App-server transport
|
||||
|
||||
For ordinary harness turns, OpenClaw starts the managed Codex binary shipped
|
||||
with the official plugin (currently `@openai/codex` `0.147.0`):
|
||||
with the official plugin (currently `@openai/codex` `0.149.0`):
|
||||
|
||||
```bash
|
||||
codex app-server --listen stdio://
|
||||
@@ -317,10 +317,11 @@ If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
permission profile instead. Codex-managed network enforcement is sandboxed
|
||||
networking, so a full-access profile would not protect outbound traffic.
|
||||
|
||||
The plugin accepts exactly stable Codex app-server `0.147.0`. Older or newer
|
||||
versions, prereleases, build-suffixed versions, and unversioned app-server
|
||||
handshakes are rejected. The same exact-version requirement applies to explicit
|
||||
custom executables, remote app-servers, and macOS desktop binaries.
|
||||
The plugin manages stable Codex app-server `0.149.0`. Explicit custom
|
||||
executables, remote app-servers, and macOS desktop binaries must report a
|
||||
parseable semantic version of `0.149.0` or newer. Older, malformed, and
|
||||
unversioned handshakes are rejected. Newer versions log a compatibility warning
|
||||
and continue through normal runtime and capability validation.
|
||||
|
||||
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
|
||||
identity-bearing WebSocket auth through `appServer.authToken` or an
|
||||
@@ -369,7 +370,7 @@ configured plugin's details to reserve the denied app IDs. It does not scan
|
||||
unrelated marketplaces or install, enable, or authenticate the disabled plugin;
|
||||
missing ownership fails closed.
|
||||
|
||||
Only connect OpenClaw to a `0.147.0` remote app-server trusted to accept
|
||||
Only connect OpenClaw to a `0.149.0` or newer remote app-server trusted to accept
|
||||
configured marketplace plugin installs and inventory refreshes. Missing modern
|
||||
inventory methods and server, authentication, or transport failures fail closed.
|
||||
|
||||
@@ -445,7 +446,7 @@ The stable default is fail-closed: active OpenClaw sandboxing disables native
|
||||
Codex execution surfaces that would otherwise run from the Codex app-server
|
||||
host. Use `appServer.experimental.sandboxExecServer: true` only when you want
|
||||
to try Codex's remote environment support with OpenClaw's sandbox backend.
|
||||
This preview path uses the pinned Codex `0.147.0` app-server.
|
||||
This preview path uses the pinned Codex `0.149.0` app-server.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -731,9 +732,9 @@ response remains authoritative even if it contains no visible models; HTTP
|
||||
`401` and `403` return an empty catalog rather than exposing fallback models.
|
||||
|
||||
<Note>
|
||||
The current bundled harness is `@openai/codex` `0.147.0`. A live `model/list`
|
||||
probe against the official `0.147.0` app-server returned these public picker
|
||||
rows:
|
||||
The current bundled harness is `@openai/codex` `0.149.0`. The following table is
|
||||
an example `0.149.0` public picker snapshot, not a current authenticated
|
||||
`model/list` result:
|
||||
|
||||
| Model id | Input modalities | Reasoning efforts |
|
||||
| --------------- | ---------------- | ------------------------------- |
|
||||
|
||||
@@ -74,10 +74,11 @@ channel is the communication surface.
|
||||
|
||||
- The official `@openclaw/codex` plugin installed. Include `codex` in
|
||||
`plugins.allow` if your config uses an allowlist.
|
||||
- Codex app-server `0.147.0`. The plugin ships and manages `@openai/codex`
|
||||
`0.147.0` by default, so a `codex` command on `PATH` does not affect normal
|
||||
- Codex app-server `0.149.0`. The plugin ships and manages `@openai/codex`
|
||||
`0.149.0` by default, so a `codex` command on `PATH` does not affect normal
|
||||
startup. Explicit custom, remote, and macOS desktop-owned app-servers must
|
||||
report the same exact stable `0.147.0` version.
|
||||
report a parseable semantic version of `0.149.0` or newer. Newer versions
|
||||
continue with a compatibility warning and normal runtime validation.
|
||||
- Node.js on the remote Codex app-server host when `remoteWorkspaceRoot` is set
|
||||
and cross-machine workspace attachments must be transferred.
|
||||
- Codex auth through `openclaw models auth login --provider openai`, an
|
||||
@@ -1292,10 +1293,10 @@ instead of a plain OpenAI API-key failure.
|
||||
Doctor rewrites legacy model refs to `openai/*`, removes stale session and
|
||||
whole-agent runtime pins, and preserves existing auth-profile overrides.
|
||||
|
||||
**The app-server is rejected:** use exactly stable Codex `0.147.0`. Older or
|
||||
newer versions, prereleases, build-suffixed versions, and unversioned servers
|
||||
are rejected because OpenClaw validates generated schemas and runtime contracts
|
||||
against the Codex version it ships. Update or remove custom, remote, or desktop
|
||||
**The app-server is rejected:** use Codex `0.149.0` or newer. Older, malformed,
|
||||
and unversioned servers are rejected. Newer semantic versions continue with a
|
||||
compatibility warning and normal runtime validation against the Codex version
|
||||
OpenClaw ships. Update or remove custom, remote, or desktop
|
||||
binary overrides that select another version.
|
||||
|
||||
**`/codex status` cannot connect:** check that the `codex` plugin
|
||||
|
||||
@@ -22,9 +22,9 @@ working.
|
||||
- The agent runtime must be the native Codex harness.
|
||||
- `plugins.entries.codex.enabled` is `true`.
|
||||
- `plugins.entries.codex.config.codexPlugins.enabled` is `true`.
|
||||
- Codex app-server reports exactly stable `0.147.0`. The official plugin ships
|
||||
`@openai/codex` `0.147.0`; custom, remote, and macOS desktop-owned binaries
|
||||
must use the same exact version.
|
||||
- Codex app-server reports `0.149.0` or newer. The official plugin ships
|
||||
`@openai/codex` `0.149.0`; newer custom, remote, and macOS desktop-owned
|
||||
binaries continue with a compatibility warning and normal runtime validation.
|
||||
- The target Codex app-server can see the expected marketplace, plugin, and
|
||||
app inventory.
|
||||
- Migration supports only `openai-curated` plugins that it observed as
|
||||
|
||||
@@ -319,8 +319,8 @@ For operator setup, model prefix examples, and Codex-only configs, see
|
||||
|
||||
The Codex plugin enforces the minimum app-server version documented in
|
||||
[Codex Harness](/plugins/codex-harness). It checks the initialize handshake and
|
||||
blocks older or unversioned servers, so OpenClaw only runs against the protocol
|
||||
surface it has tested.
|
||||
blocks older, malformed, or unversioned servers. Admission permits startup to
|
||||
continue; it does not prove later runtime or capability operations will succeed.
|
||||
|
||||
### Tool-result middleware
|
||||
|
||||
|
||||
@@ -181,8 +181,9 @@ describe("codex doctor contract", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the retired on-failure app-server approval policy", () => {
|
||||
it("reports retired app-server approval policies", () => {
|
||||
expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-failure" })).toBe(true);
|
||||
expect(legacyConfigRules[2]?.match({ approvalPolicy: "untrusted" })).toBe(true);
|
||||
expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-request" })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1366,7 +1367,7 @@ describe("codex doctor contract", () => {
|
||||
const result = normalizeCompatibilityConfig({ cfg: original });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'Renamed plugins.entries.codex.config.appServer.approvalPolicy="on-failure" to "on-request".',
|
||||
'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".',
|
||||
]);
|
||||
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
||||
appServer: {
|
||||
@@ -1376,5 +1377,36 @@ describe("codex doctor contract", () => {
|
||||
});
|
||||
expect(original.plugins.entries.codex.config.appServer.approvalPolicy).toBe("on-failure");
|
||||
});
|
||||
|
||||
it("renames the retired app-server untrusted approval policy", () => {
|
||||
const original = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
approvalPolicy: "untrusted",
|
||||
sandbox: "workspace-write",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: original });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".',
|
||||
]);
|
||||
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
||||
appServer: {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
},
|
||||
});
|
||||
expect(original.plugins.entries.codex.config.appServer.approvalPolicy).toBe("untrusted");
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -28,8 +28,9 @@ function hasLegacyPluginDestructivePolicy(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasRetiredOnFailureApprovalPolicy(value: unknown): boolean {
|
||||
return asNullableRecord(value)?.approvalPolicy === "on-failure";
|
||||
function hasRetiredApprovalPolicy(value: unknown): boolean {
|
||||
const approvalPolicy = asNullableRecord(value)?.approvalPolicy;
|
||||
return approvalPolicy === "on-failure" || approvalPolicy === "untrusted";
|
||||
}
|
||||
|
||||
/** Legacy Codex config keys that doctor should report or repair. */
|
||||
@@ -49,8 +50,8 @@ export const legacyConfigRules: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["plugins", "entries", "codex", "config", "appServer"],
|
||||
message:
|
||||
'plugins.entries.codex.config.appServer.approvalPolicy="on-failure" was retired by Codex 0.143; use "on-request". Run "openclaw doctor --fix".',
|
||||
match: hasRetiredOnFailureApprovalPolicy,
|
||||
'plugins.entries.codex.config.appServer.approvalPolicy values "on-failure" and "untrusted" are retired; use "on-request". Run "openclaw doctor --fix".',
|
||||
match: hasRetiredApprovalPolicy,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -68,7 +69,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
|
||||
const shouldRemoveDynamicToolsProfile =
|
||||
rawPluginConfig !== null && hasRetiredDynamicToolsProfile(rawPluginConfig);
|
||||
const shouldRewriteDestructivePolicy = hasLegacyPluginDestructivePolicy(rawCodexPlugins);
|
||||
const shouldRewriteApprovalPolicy = hasRetiredOnFailureApprovalPolicy(rawAppServer);
|
||||
const shouldRewriteApprovalPolicy = hasRetiredApprovalPolicy(rawAppServer);
|
||||
if (
|
||||
!rawPluginConfig ||
|
||||
(!shouldRemoveDynamicToolsProfile &&
|
||||
@@ -116,11 +117,14 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
|
||||
|
||||
if (shouldRewriteApprovalPolicy) {
|
||||
const nextAppServer = asNullableRecord(nextPluginConfig.appServer);
|
||||
if (nextAppServer?.approvalPolicy === "on-failure") {
|
||||
if (
|
||||
nextAppServer?.approvalPolicy === "on-failure" ||
|
||||
nextAppServer?.approvalPolicy === "untrusted"
|
||||
) {
|
||||
nextAppServer.approvalPolicy = "on-request";
|
||||
}
|
||||
changes.push(
|
||||
'Renamed plugins.entries.codex.config.appServer.approvalPolicy="on-failure" to "on-request".',
|
||||
'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { CodexSessionCatalogControlFactory } from "./src/session-catalog-ty
|
||||
// New runtime identity uses the `openai` provider.
|
||||
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
|
||||
const SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER = Symbol.for("openclaw.codexAppServerClientDisposer");
|
||||
// Audited against @openai/codex 0.147.0 (rust-v0.147.0). These exact denies
|
||||
// Audited against @openai/codex 0.149.0 (rust-v0.149.0). These exact denies
|
||||
// either have no Codex-native equivalent or are enforced by the harness. Keep
|
||||
// the list positive and conservative: an omitted tool isolates the native surface.
|
||||
const CODEX_TOOL_POLICY_SAFE_DENY_NAMES = [
|
||||
|
||||
@@ -48,7 +48,8 @@ function threadStartResult() {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.147.0",
|
||||
"@openai/codex": "0.149.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "1.7.1",
|
||||
"typebox": "1.3.6",
|
||||
"ws": "8.21.1",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeNotificationActivity,
|
||||
isAssistantCommentaryCompletionNotification,
|
||||
isAssistantCompletionReleaseNotification,
|
||||
isCodexTurnAbortMarkerNotification,
|
||||
} from "./attempt-notifications.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
@@ -40,6 +42,26 @@ describe("describeNotificationActivity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant completion classification", () => {
|
||||
it("keeps async agent messages on the nonterminal completion lane", () => {
|
||||
const notification: CodexServerNotification = {
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
id: "async-message-1",
|
||||
type: "agentMessage",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
text: "Background agent update.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(isAssistantCompletionReleaseNotification(notification, false)).toBe(false);
|
||||
expect(isAssistantCommentaryCompletionNotification(notification)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCodexTurnAbortMarkerNotification", () => {
|
||||
it("accepts a wrapped user marker", () => {
|
||||
expect(
|
||||
|
||||
@@ -106,7 +106,8 @@ function isCompletedAssistantNotification(notification: CodexServerNotification)
|
||||
return Boolean(
|
||||
item &&
|
||||
readString(item, "type") === "agentMessage" &&
|
||||
readString(item, "phase") !== "commentary",
|
||||
readString(item, "phase") !== "commentary" &&
|
||||
readString(item, "delivery") !== "async",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ export function isAssistantCommentaryCompletionNotification(
|
||||
return Boolean(
|
||||
item &&
|
||||
readString(item, "type") === "agentMessage" &&
|
||||
readString(item, "phase") === "commentary",
|
||||
(readString(item, "phase") === "commentary" || readString(item, "delivery") === "async"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ async function captureExpectedRuntimeArtifact(
|
||||
before,
|
||||
startOptions: appServer.start,
|
||||
spawnIdentity,
|
||||
runtimeIdentity: { serverVersion: "0.147.0", userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
runtimeIdentity: { serverVersion: "0.149.0", userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ async function answerInitialize(harness: ClientHarness): Promise<void> {
|
||||
timeout: HARNESS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.147.0 (macOS; test)" } });
|
||||
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.149.0 (macOS; test)" } });
|
||||
}
|
||||
|
||||
async function waitForRequest(
|
||||
|
||||
@@ -38,6 +38,7 @@ function threadStartResult() {
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
cwd: "/tmp/finalizer",
|
||||
projectId: null,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./client.js";
|
||||
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
import { CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
|
||||
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 660_000;
|
||||
|
||||
@@ -331,7 +331,7 @@ describe("CodexAppServerClient", () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
|
||||
await expect(initializing).resolves.toBeUndefined();
|
||||
@@ -361,48 +361,49 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.124.9`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.124.9`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks the previously bundled Codex app-server version", async () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: "openclaw/0.146.0 (macOS; test)" },
|
||||
});
|
||||
it.each(["0.147.0", "0.148.0"])(
|
||||
"blocks previously bundled Codex app-server version %s",
|
||||
async (version) => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: `openclaw/${version} (macOS; test)` },
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.146.0`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${version}`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks Codex app-server prereleases of the exact supported version", async () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: "openclaw/0.147.0-alpha.2 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0-alpha.2 (macOS; test)" },
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.147.0-alpha.2`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.149.0-alpha.2`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks Codex app-server build metadata on the exact supported version", async () => {
|
||||
it("accepts build metadata on the exact supported version", async () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: "openclaw/0.147.0+alpha.2 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0+alpha.2 (macOS; test)" },
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.147.0+alpha.2`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
await expect(initializing).resolves.toBeUndefined();
|
||||
expect(harness.writes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("blocks Codex app-server prereleases outside generated stable schemas", async () => {
|
||||
@@ -413,7 +414,7 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
@@ -426,31 +427,39 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks stable Codex app-server versions newer than generated schemas", async () => {
|
||||
const newerVersion = "0.146.2";
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: `openclaw/${newerVersion} (macOS; test)` },
|
||||
});
|
||||
it.each(["0.150.0-alpha.1", "0.150.0"])(
|
||||
"accepts a newer app-server version %s for normal startup validation",
|
||||
async (newerVersion) => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
result: { userAgent: `openclaw/${newerVersion} (macOS; test)` },
|
||||
});
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
await expect(initializing).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"codex app-server is newer than OpenClaw's managed runtime; continuing with normal startup validation",
|
||||
{
|
||||
detectedVersion: newerVersion,
|
||||
validatedVersion: CODEX_APP_SERVER_VERSION,
|
||||
},
|
||||
);
|
||||
expect(harness.writes).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks app-server initialize responses without a version", async () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({ id: outbound.id, result: {} });
|
||||
|
||||
await expect(initializing).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`,
|
||||
);
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-ha
|
||||
import { coerceErrorMessage, toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { parse as parseSemver } from "semver";
|
||||
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
|
||||
import {
|
||||
type CodexAppServerRequestMethod,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
closeCodexAppServerTransportAndWait,
|
||||
type CodexAppServerTransport,
|
||||
} from "./transport.js";
|
||||
import { CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
|
||||
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
|
||||
const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 8 * 1024 * 1024;
|
||||
@@ -999,7 +1000,7 @@ class CodexAppServerVersionError extends Error {
|
||||
? `detected ${detectedVersion}`
|
||||
: "OpenClaw could not determine the running Codex version";
|
||||
super(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
|
||||
);
|
||||
this.name = "CodexAppServerVersionError";
|
||||
this.detectedVersion = detectedVersion;
|
||||
@@ -1008,9 +1009,22 @@ class CodexAppServerVersionError extends Error {
|
||||
|
||||
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): string {
|
||||
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
|
||||
if (detectedVersion !== CODEX_APP_SERVER_VERSION) {
|
||||
if (!detectedVersion) {
|
||||
throw new CodexAppServerVersionError(detectedVersion);
|
||||
}
|
||||
const detected = parseSemver(detectedVersion);
|
||||
if (!detected || detected.compare(MIN_SUPPORTED_CODEX_APP_SERVER_VERSION) < 0) {
|
||||
throw new CodexAppServerVersionError(detectedVersion);
|
||||
}
|
||||
if (detected.compare(CODEX_APP_SERVER_VERSION) > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server is newer than OpenClaw's managed runtime; continuing with normal startup validation",
|
||||
{
|
||||
detectedVersion,
|
||||
validatedVersion: CODEX_APP_SERVER_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
return detectedVersion;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ export function threadStartResult(threadId = "thread-1", cwd = "/tmp/openclaw-co
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd,
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -2325,6 +2325,7 @@ function createFakeCodexClient(
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
projectId: null,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
|
||||
@@ -156,6 +156,74 @@ describe("Codex Computer Use setup", () => {
|
||||
expect(sharedClientMocks.releaseLeasedSharedCodexAppServerClient).toHaveBeenCalledWith(client);
|
||||
});
|
||||
|
||||
it("releases the install mutation fence before the guarded readiness thread", async () => {
|
||||
const agentDir = "/tmp/openclaw-computer-use-guarded-install-agent";
|
||||
const pluginConfig = {
|
||||
computerUse: { marketplaceName: "desktop-tools", liveTestTimeoutMs: 150 },
|
||||
};
|
||||
const startOptions = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig,
|
||||
managedCommandOrder: "desktop-first",
|
||||
}).start;
|
||||
const fenceKey = resolveCodexNativeConfigFenceKey({ startOptions, agentDir });
|
||||
expect(fenceKey).toBeTypeOf("string");
|
||||
|
||||
const harness = createClientHarness();
|
||||
harness.client.setThreadSessionRequestGuard((options) =>
|
||||
acquireCodexNativeConfigFence(fenceKey as string, options),
|
||||
);
|
||||
sharedClientMocks.getLeasedSharedCodexAppServerClient.mockResolvedValueOnce(harness.client);
|
||||
const fixture = createComputerUseRequest({ installed: false });
|
||||
let cursor = 0;
|
||||
const readFrame = async (method: string) => {
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(cursor), {
|
||||
timeout: 1_000,
|
||||
});
|
||||
const frame = JSON.parse(harness.writes[cursor++] ?? "{}") as {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(frame.method).toBe(method);
|
||||
return frame;
|
||||
};
|
||||
const answerFrame = async (frame: { id: number; method: string; params?: unknown }) => {
|
||||
const result = await fixture(frame.method, frame.params);
|
||||
harness.send({ id: frame.id, result: result ?? null });
|
||||
};
|
||||
const answer = async (method: string) => answerFrame(await readFrame(method));
|
||||
|
||||
const install = installCodexComputerUse({ pluginConfig, agentDir, timeoutMs: 2_000 });
|
||||
void install.catch(() => undefined);
|
||||
await answer("experimentalFeature/enablement/set");
|
||||
await answer("plugin/list");
|
||||
await answer("plugin/read");
|
||||
const mutation = await readFrame("plugin/install");
|
||||
await expect(
|
||||
acquireCodexNativeConfigFence(fenceKey as string, {
|
||||
timeoutMs: 10,
|
||||
timeoutMessage: "mutation fence held",
|
||||
}),
|
||||
).rejects.toThrow("mutation fence held");
|
||||
await answerFrame(mutation);
|
||||
await answer("config/mcpServer/reload");
|
||||
await answer("plugin/read");
|
||||
await answer("mcpServerStatus/list");
|
||||
await answer("thread/start");
|
||||
await answer("mcpServer/tool/call");
|
||||
await answer("thread/unsubscribe");
|
||||
await answer("thread/archive");
|
||||
|
||||
await expect(install).resolves.toMatchObject({
|
||||
ready: true,
|
||||
liveTest: { status: "passed", attempts: 1 },
|
||||
});
|
||||
expect(sharedClientMocks.releaseLeasedSharedCodexAppServerClient).toHaveBeenCalledWith(
|
||||
harness.client,
|
||||
);
|
||||
harness.client.close();
|
||||
});
|
||||
|
||||
it.each(["abort", "timeout"] as const)(
|
||||
"holds the install fence through process exit after a post-write %s",
|
||||
async (mode) => {
|
||||
|
||||
@@ -138,6 +138,7 @@ export type CodexComputerUseSetupParams = {
|
||||
defaultBundledMarketplacePath?: string;
|
||||
defaultBundledMarketplacePathCandidates?: readonly string[];
|
||||
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
|
||||
releaseNativeConfigFence?: () => void;
|
||||
};
|
||||
|
||||
type CodexComputerUseInspectionParams = {
|
||||
@@ -153,6 +154,7 @@ type CodexComputerUseInspectionParams = {
|
||||
defaultBundledMarketplacePath?: string;
|
||||
defaultBundledMarketplacePathCandidates?: readonly string[];
|
||||
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
|
||||
releaseNativeConfigFence?: () => void;
|
||||
};
|
||||
|
||||
type MarketplaceRef =
|
||||
@@ -331,6 +333,7 @@ async function inspectCodexComputerUse(
|
||||
try {
|
||||
return await inspectCodexComputerUseWithoutFence({
|
||||
...params,
|
||||
releaseNativeConfigFence: release,
|
||||
...(client
|
||||
? {
|
||||
client,
|
||||
@@ -411,6 +414,7 @@ async function inspectCodexComputerUseWithoutFence(
|
||||
plugin: pluginInspection.plugin,
|
||||
installPlugin: params.installPlugin,
|
||||
repairComputerUseMcpChildren,
|
||||
releaseNativeConfigFence: params.releaseNativeConfigFence,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -470,6 +474,7 @@ async function readComputerUseTools(params: {
|
||||
plugin: CodexPluginDetail;
|
||||
installPlugin: boolean;
|
||||
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
|
||||
releaseNativeConfigFence?: () => void;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
let server = await readMcpServerStatus(params.request, params.config.mcpServerName);
|
||||
let tools = Object.keys(server?.tools ?? {}).toSorted();
|
||||
@@ -504,6 +509,8 @@ async function readComputerUseTools(params: {
|
||||
reason: "ready",
|
||||
message: "Computer Use is ready.",
|
||||
});
|
||||
// The readiness thread reacquires this fence before loading native config.
|
||||
params.releaseNativeConfigFence?.();
|
||||
const { liveTest, repair } = await runCodexComputerUseLiveTest({
|
||||
request: params.request,
|
||||
config: params.config,
|
||||
|
||||
@@ -34,9 +34,9 @@ export type CodexAppServerDefaultPolicy = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
dangerFullAccessAllowed?: boolean;
|
||||
};
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "untrusted";
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request";
|
||||
export type CodexAppServerApprovalPolicySource = "config" | "env" | "requirements" | "implicit";
|
||||
export type CodexAppServerEffectiveApprovalPolicy = CodexApprovalPolicy;
|
||||
export type CodexAppServerEffectiveApprovalPolicy = Exclude<CodexApprovalPolicy, "untrusted">;
|
||||
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
||||
export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
|
||||
|
||||
@@ -65,10 +65,15 @@ export function selectGuardianSandbox(
|
||||
}
|
||||
|
||||
export function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
|
||||
if (value === "untrusted") {
|
||||
throw new Error(
|
||||
'Codex app-server approval policy "untrusted" is retired; run "openclaw doctor --fix" and use "on-request".',
|
||||
);
|
||||
}
|
||||
if (value === "on-failure") {
|
||||
return "on-request";
|
||||
}
|
||||
return value === "on-request" || value === "untrusted" || value === "never" ? value : undefined;
|
||||
return value === "on-request" || value === "never" ? value : undefined;
|
||||
}
|
||||
|
||||
export function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
||||
import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -30,10 +31,8 @@ const codexAppServerHomeScopeSchema = z.enum(["agent", "user"]);
|
||||
const SecretInputSchema = buildSecretInputSchema();
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
const codexAppServerApprovalPolicySchema = z.preprocess(
|
||||
// Preserve the rest of a shipped plugin config until doctor persists the
|
||||
// canonical value. Rejecting this field would discard the whole config.
|
||||
(value) => (value === "on-failure" ? "on-request" : value),
|
||||
z.enum(["never", "on-request", "untrusted"]),
|
||||
z.enum(["never", "on-request"]),
|
||||
);
|
||||
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
|
||||
@@ -194,6 +193,12 @@ const codexPluginConfigSchema = z
|
||||
.strict();
|
||||
|
||||
export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
|
||||
const appServer = asNullableRecord(asNullableRecord(value)?.appServer);
|
||||
if (appServer?.approvalPolicy === "untrusted") {
|
||||
throw new Error(
|
||||
'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".',
|
||||
);
|
||||
}
|
||||
const parsed = codexPluginConfigSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return {};
|
||||
|
||||
@@ -67,6 +67,14 @@ export function parseAllowedApprovalPoliciesFromCodexRequirements(
|
||||
const normalizedPolicies = values
|
||||
.map((entry) => normalizeRequirementsApprovalPolicy(entry))
|
||||
.filter((entry): entry is CodexAppServerApprovalPolicy => entry !== undefined);
|
||||
if (
|
||||
normalizedPolicies.length === 0 &&
|
||||
values.some((entry) => entry.trim().toLowerCase() === "untrusted")
|
||||
) {
|
||||
throw new Error(
|
||||
'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".',
|
||||
);
|
||||
}
|
||||
return normalizedPolicies.length > 0 ? new Set(normalizedPolicies) : undefined;
|
||||
}
|
||||
|
||||
@@ -302,6 +310,9 @@ function normalizeRequirementsApprovalPolicy(
|
||||
if (normalized === "on-failure") {
|
||||
return "on-request";
|
||||
}
|
||||
if (normalized === "untrusted") {
|
||||
return undefined;
|
||||
}
|
||||
return resolveApprovalPolicy(normalized);
|
||||
}
|
||||
|
||||
@@ -324,9 +335,6 @@ export function selectGuardianApprovalPolicy(
|
||||
`tools.exec.mode=${execModeRequiringPromptingApprovals} requires Codex app-server prompting approvals`,
|
||||
);
|
||||
}
|
||||
if (allowedApprovalPolicies.has("untrusted")) {
|
||||
return "untrusted";
|
||||
}
|
||||
if (allowedApprovalPolicies.has("never")) {
|
||||
return "never";
|
||||
}
|
||||
|
||||
@@ -2447,13 +2447,31 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ execMode: "auto", policies: ["never"] },
|
||||
{ execMode: "auto", policies: ["untrusted"] },
|
||||
{ execMode: "ask", policies: ["never"] },
|
||||
{ execMode: "ask", policies: ["untrusted"] },
|
||||
{
|
||||
execMode: "auto",
|
||||
policies: ["never"],
|
||||
error: "tools.exec.mode=auto requires Codex app-server prompting approvals",
|
||||
},
|
||||
{
|
||||
execMode: "auto",
|
||||
policies: ["untrusted"],
|
||||
error:
|
||||
'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".',
|
||||
},
|
||||
{
|
||||
execMode: "ask",
|
||||
policies: ["never"],
|
||||
error: "tools.exec.mode=ask requires Codex app-server prompting approvals",
|
||||
},
|
||||
{
|
||||
execMode: "ask",
|
||||
policies: ["untrusted"],
|
||||
error:
|
||||
'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".',
|
||||
},
|
||||
] as const)(
|
||||
"fails closed when normalized OpenClaw $execMode mode can only use $policies approvals",
|
||||
({ execMode, policies }) => {
|
||||
({ execMode, policies, error }) => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
@@ -2462,7 +2480,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
.map((policy) => `"${policy}"`)
|
||||
.join(", ")}]\n`,
|
||||
}),
|
||||
).toThrow(`tools.exec.mode=${execMode} requires Codex app-server prompting approvals`);
|
||||
).toThrow(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2978,6 +2996,24 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the retired untrusted approval policy at runtime", () => {
|
||||
expect(() =>
|
||||
readCodexPluginConfig({
|
||||
appServer: { approvalPolicy: "untrusted" },
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".',
|
||||
);
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "untrusted" },
|
||||
}),
|
||||
).toThrow(
|
||||
'Codex app-server approval policy "untrusted" is retired; run "openclaw doctor --fix" and use "on-request".',
|
||||
);
|
||||
});
|
||||
|
||||
it("derives distinct shared-client keys for distinct auth tokens without exposing them", () => {
|
||||
const first = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
|
||||
@@ -28,6 +28,10 @@ export type AssistantMessageOptions = {
|
||||
promptError: unknown;
|
||||
};
|
||||
|
||||
export type CodexAsyncAssistantMessage = AssistantMessage & {
|
||||
openclawAsyncDelivery: { itemId: string };
|
||||
};
|
||||
|
||||
const ZERO_USAGE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@@ -125,6 +129,26 @@ export function createAssistantCommentaryMessage(
|
||||
return message;
|
||||
}
|
||||
|
||||
export function createAssistantAsyncMessage(
|
||||
params: CodexAssistantMessageParams,
|
||||
text: string,
|
||||
itemId: string,
|
||||
timestamp: number,
|
||||
): CodexAsyncAssistantMessage {
|
||||
const attribution = resolveCodexLocalRuntimeAttribution(params);
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: attribution.api ?? "openai-chatgpt-responses",
|
||||
provider: attribution.provider,
|
||||
model: params.modelId,
|
||||
usage: ZERO_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
openclawAsyncDelivery: { itemId },
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssistantMirrorMessage(
|
||||
params: CodexAssistantMessageParams,
|
||||
title: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
|
||||
import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createAssistantAsyncMessage as buildAssistantAsyncMessage,
|
||||
createAssistantCommentaryMessage as buildAssistantCommentaryMessage,
|
||||
createAssistantMessage as buildAssistantMessage,
|
||||
createAssistantMirrorMessage as buildAssistantMirrorMessage,
|
||||
@@ -20,6 +21,7 @@ export class CodexAssistantProjection {
|
||||
private readonly assistantItemOrder: string[] = [];
|
||||
private readonly assistantTimestampByItem = new Map<string, number>();
|
||||
private readonly assistantPhaseByItem = new Map<string, string>();
|
||||
private readonly assistantDeliveryByItem = new Map<string, string>();
|
||||
private latestCompletedItemId: string | undefined;
|
||||
private latestCompletedTerminalAssistantItemId: string | undefined;
|
||||
private latestTerminalAssistantCandidateItemId: string | undefined;
|
||||
@@ -110,7 +112,8 @@ export class CodexAssistantProjection {
|
||||
}
|
||||
// Deltas carry no phase; item/started has already recorded it.
|
||||
const isCommentary = this.isCommentaryAssistantItem(itemId);
|
||||
if (!isCommentary && itemId !== this.latestTerminalAssistantCandidateItemId) {
|
||||
const isAsync = this.isAsyncAssistantItem(itemId);
|
||||
if (!isCommentary && !isAsync && itemId !== this.latestTerminalAssistantCandidateItemId) {
|
||||
this.markTerminalAssistantCandidateSupersededBy();
|
||||
}
|
||||
if (!this.assistantStarted) {
|
||||
@@ -120,6 +123,9 @@ export class CodexAssistantProjection {
|
||||
this.rememberAssistantItem(itemId);
|
||||
const text = `${this.assistantTextByItem.get(itemId) ?? ""}${delta}`;
|
||||
this.assistantTextByItem.set(itemId, text);
|
||||
if (isAsync) {
|
||||
return;
|
||||
}
|
||||
if (isCommentary) {
|
||||
this.emitCommentaryProgress({ itemId, text });
|
||||
return;
|
||||
@@ -162,18 +168,23 @@ export class CodexAssistantProjection {
|
||||
|
||||
recordItemStarted(item: CodexThreadItem | undefined, itemId: string | undefined): void {
|
||||
this.noteNativeWorkBarrier(item);
|
||||
this.rememberAssistantPhase(item);
|
||||
if (
|
||||
item?.type === "agentMessage" &&
|
||||
itemId &&
|
||||
!this.isNonTerminalAssistantItem(itemId) &&
|
||||
itemId !== this.pendingRawTerminalAssistantEchoItemId
|
||||
) {
|
||||
this.pendingRawTerminalAssistantEchoItemId = undefined;
|
||||
}
|
||||
this.rememberAssistantPhase(item);
|
||||
if (item?.type === "agentMessage" && itemId) {
|
||||
this.rememberAssistantItem(itemId);
|
||||
}
|
||||
if (itemId && itemId !== this.latestTerminalAssistantCandidateItemId) {
|
||||
if (
|
||||
itemId &&
|
||||
!this.isNonTerminalAssistantItem(itemId) &&
|
||||
itemId !== this.latestTerminalAssistantCandidateItemId
|
||||
) {
|
||||
this.markTerminalAssistantCandidateSupersededBy(itemId, {
|
||||
preserveEarlierActiveItem: true,
|
||||
});
|
||||
@@ -187,24 +198,25 @@ export class CodexAssistantProjection {
|
||||
item: CodexThreadItem | undefined,
|
||||
itemId: string | undefined,
|
||||
activeItemIds: ReadonlySet<string>,
|
||||
): void {
|
||||
): { itemId: string; message: AssistantMessage; text: string } | undefined {
|
||||
this.noteNativeWorkBarrier(item);
|
||||
this.rememberAssistantPhase(item);
|
||||
if (
|
||||
item?.type === "agentMessage" &&
|
||||
itemId &&
|
||||
!this.isNonTerminalAssistantItem(itemId) &&
|
||||
itemId !== this.pendingRawTerminalAssistantEchoItemId
|
||||
) {
|
||||
this.pendingRawTerminalAssistantEchoItemId = undefined;
|
||||
}
|
||||
if (itemId) {
|
||||
if (itemId && !this.isNonTerminalAssistantItem(itemId)) {
|
||||
this.latestCompletedItemId = itemId;
|
||||
}
|
||||
this.rememberAssistantPhase(item);
|
||||
if (item?.type === "agentMessage" && !this.isCommentaryAssistantItem(item.id)) {
|
||||
if (item?.type === "agentMessage" && !this.isNonTerminalAssistantItem(item.id)) {
|
||||
this.latestCompletedTerminalAssistantItemId = item.id;
|
||||
this.markLatestTerminalAssistantCandidate(item.id, activeItemIds);
|
||||
this.pendingRawTerminalAssistantEchoItemId = item.id;
|
||||
} else if (itemId) {
|
||||
} else if (itemId && !this.isNonTerminalAssistantItem(itemId)) {
|
||||
this.markTerminalAssistantCandidateSupersededBy(itemId, {
|
||||
preserveEarlierActiveItem: true,
|
||||
});
|
||||
@@ -218,18 +230,28 @@ export class CodexAssistantProjection {
|
||||
if (item.text && this.isCommentaryAssistantItem(item.id)) {
|
||||
this.emitCommentaryProgress({ itemId: item.id, text: item.text });
|
||||
this.pendingRawCommentaryEchoes += 1;
|
||||
} else if (item.text && this.isFinalAnswerAssistantItem(item.id)) {
|
||||
} else if (
|
||||
item.text &&
|
||||
!this.isAsyncAssistantItem(item.id) &&
|
||||
this.isFinalAnswerAssistantItem(item.id)
|
||||
) {
|
||||
this.emitAnswerCandidate(item.id, "candidate");
|
||||
}
|
||||
return this.createAsyncDelivery(item.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
recordSnapshotItem(item: CodexThreadItem): void {
|
||||
recordSnapshotItem(
|
||||
item: CodexThreadItem,
|
||||
): { itemId: string; message: AssistantMessage; text: string } | undefined {
|
||||
this.rememberAssistantPhase(item);
|
||||
if (item.type === "agentMessage" && typeof item.text === "string") {
|
||||
this.rememberAssistantItem(item.id);
|
||||
this.assistantTextByItem.set(item.id, item.text);
|
||||
return this.createAsyncDelivery(item.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleRawResponseItemCompleted(item: JsonObject, activeItemIds: ReadonlySet<string>): void {
|
||||
@@ -352,6 +374,25 @@ export class CodexAssistantProjection {
|
||||
});
|
||||
}
|
||||
|
||||
collectAsyncMessages(): Array<{ itemId: string; message: AssistantMessage }> {
|
||||
return this.assistantItemOrder.flatMap((itemId) => {
|
||||
if (!this.isAsyncAssistantItem(itemId)) {
|
||||
return [];
|
||||
}
|
||||
const text = this.assistantTextByItem.get(itemId)?.trim();
|
||||
const timestamp = this.assistantTimestampByItem.get(itemId);
|
||||
if (!text || timestamp === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
itemId,
|
||||
message: buildAssistantAsyncMessage(this.params, text, itemId, timestamp),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
finalizeAnswerCandidate(turn: { status?: string; items?: CodexThreadItem[] }): void {
|
||||
if (turn.status !== "completed") {
|
||||
this.supersedeVisibleAnswerCandidate();
|
||||
@@ -367,7 +408,8 @@ export class CodexAssistantProjection {
|
||||
return false;
|
||||
}
|
||||
const phase = readItemString(item, "phase");
|
||||
return phase === "final_answer" || phase === undefined;
|
||||
const delivery = readItemString(item, "delivery");
|
||||
return delivery !== "async" && (phase === "final_answer" || phase === undefined);
|
||||
});
|
||||
const authoritative = authoritativeIndex >= 0 ? turnItems[authoritativeIndex] : undefined;
|
||||
const invalidatedByLaterTool = turnItems
|
||||
@@ -395,7 +437,7 @@ export class CodexAssistantProjection {
|
||||
hasAssistantItemTextForSynthesis(): boolean {
|
||||
for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) {
|
||||
const itemId = this.assistantItemOrder[i];
|
||||
if (!itemId || this.assistantPhaseByItem.get(itemId) === "commentary") {
|
||||
if (!itemId || this.isNonTerminalAssistantItem(itemId)) {
|
||||
continue;
|
||||
}
|
||||
const text = this.assistantTextByItem.get(itemId);
|
||||
@@ -413,7 +455,7 @@ export class CodexAssistantProjection {
|
||||
const itemId = this.assistantItemOrder[i];
|
||||
if (
|
||||
!itemId ||
|
||||
this.isCommentaryAssistantItem(itemId) ||
|
||||
this.isNonTerminalAssistantItem(itemId) ||
|
||||
!this.assistantTextByItem.has(itemId)
|
||||
) {
|
||||
continue;
|
||||
@@ -445,12 +487,24 @@ export class CodexAssistantProjection {
|
||||
if (phase) {
|
||||
this.assistantPhaseByItem.set(item.id, phase);
|
||||
}
|
||||
const delivery = readItemString(item, "delivery");
|
||||
if (delivery) {
|
||||
this.assistantDeliveryByItem.set(item.id, delivery);
|
||||
}
|
||||
}
|
||||
|
||||
private isCommentaryAssistantItem(itemId: string): boolean {
|
||||
return this.assistantPhaseByItem.get(itemId) === "commentary";
|
||||
}
|
||||
|
||||
private isAsyncAssistantItem(itemId: string): boolean {
|
||||
return this.assistantDeliveryByItem.get(itemId) === "async";
|
||||
}
|
||||
|
||||
private isNonTerminalAssistantItem(itemId: string): boolean {
|
||||
return this.isCommentaryAssistantItem(itemId) || this.isAsyncAssistantItem(itemId);
|
||||
}
|
||||
|
||||
private isFinalAnswerAssistantItem(itemId: string): boolean {
|
||||
return this.assistantPhaseByItem.get(itemId) === "final_answer";
|
||||
}
|
||||
@@ -560,7 +614,7 @@ export class CodexAssistantProjection {
|
||||
continue;
|
||||
}
|
||||
const text = this.assistantTextByItem.get(itemId)?.trim();
|
||||
if (this.assistantPhaseByItem.get(itemId) === "commentary") {
|
||||
if (this.isNonTerminalAssistantItem(itemId)) {
|
||||
continue;
|
||||
}
|
||||
if (text && !this.isToolProgressEchoText(itemId, text)) {
|
||||
@@ -578,7 +632,7 @@ export class CodexAssistantProjection {
|
||||
// never replace; they only ride along for post-handoff identity.
|
||||
for (let index = minIndex; index < this.assistantItemOrder.length; index += 1) {
|
||||
const itemId = this.assistantItemOrder[index];
|
||||
if (!itemId || this.assistantPhaseByItem.get(itemId) === "commentary") {
|
||||
if (!itemId || this.isNonTerminalAssistantItem(itemId)) {
|
||||
continue;
|
||||
}
|
||||
const text = this.assistantTextByItem.get(itemId)?.trim();
|
||||
@@ -621,6 +675,24 @@ export class CodexAssistantProjection {
|
||||
this.assistantTimestampByItem.set(itemId, this.nextTranscriptTimestamp());
|
||||
}
|
||||
|
||||
private createAsyncDelivery(
|
||||
itemId: string,
|
||||
): { itemId: string; message: AssistantMessage; text: string } | undefined {
|
||||
if (!this.isAsyncAssistantItem(itemId)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = this.assistantTextByItem.get(itemId);
|
||||
const timestamp = this.assistantTimestampByItem.get(itemId);
|
||||
if (!text?.trim() || timestamp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
itemId,
|
||||
message: buildAssistantAsyncMessage(this.params, text, itemId, timestamp),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
private isToolProgressEchoText(itemId: string, text: string): boolean {
|
||||
return this.rawPromotedAssistantItemIds.has(itemId) && this.matchesToolProgressEcho(text);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ export function projectNormalizedToolItem(params: {
|
||||
|
||||
export class CodexEventProjection {
|
||||
private reviewCount = 0;
|
||||
private activeGuardianReview:
|
||||
| {
|
||||
reviewId?: string;
|
||||
targetItemId?: string | null;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly threadId: string,
|
||||
@@ -114,13 +122,22 @@ export class CodexEventProjection {
|
||||
this.reviewCount += 1;
|
||||
const review = isJsonObject(params.review) ? params.review : undefined;
|
||||
const action = isJsonObject(params.action) ? params.action : undefined;
|
||||
const reviewId = readString(params, "reviewId");
|
||||
const targetItemId = readNullableString(params, "targetItemId");
|
||||
const threadId = readString(params, "threadId") ?? this.threadId;
|
||||
const turnId = readString(params, "turnId") ?? this.turnId;
|
||||
if (method.endsWith("/started")) {
|
||||
this.activeGuardianReview = { reviewId, targetItemId, threadId, turnId };
|
||||
}
|
||||
this.emitAgentEvent({
|
||||
stream: "codex_app_server.guardian",
|
||||
data: {
|
||||
method,
|
||||
phase: method.endsWith("/started") ? "started" : "completed",
|
||||
reviewId: readString(params, "reviewId"),
|
||||
targetItemId: readNullableString(params, "targetItemId"),
|
||||
threadId,
|
||||
turnId,
|
||||
reviewId,
|
||||
targetItemId,
|
||||
decisionSource: readString(params, "decisionSource"),
|
||||
status: review ? readString(review, "status") : undefined,
|
||||
riskLevel: review ? readString(review, "riskLevel") : undefined,
|
||||
@@ -130,6 +147,9 @@ export class CodexEventProjection {
|
||||
command: guardianActionCommand(action),
|
||||
},
|
||||
});
|
||||
if (method.endsWith("/completed")) {
|
||||
this.activeGuardianReview = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handleGuardianWarning(params: JsonObject): void {
|
||||
@@ -139,6 +159,22 @@ export class CodexEventProjection {
|
||||
});
|
||||
}
|
||||
|
||||
handleStrictReviewRequired(params: JsonObject): void {
|
||||
this.emitAgentEvent({
|
||||
stream: "codex_app_server.guardian",
|
||||
data: {
|
||||
method: "autoApprovalReview/strictReviewRequired",
|
||||
phase: "strict_review_required",
|
||||
threadId:
|
||||
readString(params, "threadId") ?? this.activeGuardianReview?.threadId ?? this.threadId,
|
||||
turnId: readString(params, "turnId") ?? this.activeGuardianReview?.turnId ?? this.turnId,
|
||||
reviewId: this.activeGuardianReview?.reviewId,
|
||||
targetItemId: this.activeGuardianReview?.targetItemId,
|
||||
startedAtMs: asFiniteNumber(params.startedAtMs),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleHook(method: string, params: JsonObject): void {
|
||||
const run = isJsonObject(params.run) ? params.run : undefined;
|
||||
if (!run) {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { AgentPlanStep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
|
||||
import type { CodexThreadItem, JsonValue } from "./protocol.js";
|
||||
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
|
||||
import type { CodexTrajectoryRecorder } from "./trajectory.js";
|
||||
|
||||
export type CodexAsyncDeliverySettlement = "settled" | "retry";
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
initialContextTokens?: number;
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
onAsyncDelivery?: (delivery: {
|
||||
itemId: string;
|
||||
message: AssistantMessage;
|
||||
text: string;
|
||||
}) => CodexAsyncDeliverySettlement | Promise<CodexAsyncDeliverySettlement>;
|
||||
onNativeToolResultRecorded?: () => void | Promise<void>;
|
||||
onNativePlanUpdate?: (update: {
|
||||
markdown?: string;
|
||||
|
||||
@@ -55,6 +55,7 @@ type CodexAttemptResultInput = {
|
||||
assistantProjection: Pick<
|
||||
CodexAssistantProjection,
|
||||
| "collectAssistantTexts"
|
||||
| "collectAsyncMessages"
|
||||
| "collectCommentaryMessages"
|
||||
| "createAssistantMessage"
|
||||
| "createAssistantMirrorMessage"
|
||||
@@ -84,6 +85,7 @@ export function buildCodexAttemptResult(
|
||||
// tool lacking a terminal item so audit consumers never retain an open action.
|
||||
input.nativeToolLifecycleProjection.finalizeActive();
|
||||
const assistantTexts = input.assistantProjection.collectAssistantTexts();
|
||||
const asyncMessages = input.assistantProjection.collectAsyncMessages();
|
||||
const commentaryMessages = input.assistantProjection.collectCommentaryMessages();
|
||||
const reasoningText = input.reasoningProjection.reasoningText();
|
||||
const planText = input.reasoningProjection.planText();
|
||||
@@ -152,6 +154,7 @@ export function buildCodexAttemptResult(
|
||||
upstreamUserText: input.upstreamUserText,
|
||||
reasoningText,
|
||||
planText,
|
||||
asyncMessages,
|
||||
commentaryMessages,
|
||||
toolMessages: input.toolTranscriptProjection.transcriptMessages,
|
||||
lastAssistant,
|
||||
|
||||
@@ -17,6 +17,7 @@ function buildSnapshot(trigger: EmbeddedRunAttemptParams["trigger"]): AgentMessa
|
||||
upstreamUserText: undefined,
|
||||
reasoningText: "checking memory",
|
||||
planText: undefined,
|
||||
asyncMessages: [],
|
||||
commentaryMessages: [],
|
||||
toolMessages: [
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ export function buildCodexMessagesSnapshot(params: {
|
||||
upstreamUserText: string | undefined;
|
||||
reasoningText: string | undefined;
|
||||
planText: string | undefined;
|
||||
asyncMessages: ReadonlyArray<{ itemId: string; message: AssistantMessage }>;
|
||||
commentaryMessages: ReadonlyArray<{ itemId: string; message: AssistantMessage }>;
|
||||
toolMessages: readonly AgentMessage[];
|
||||
lastAssistant: AssistantMessage | undefined;
|
||||
@@ -67,7 +68,14 @@ export function buildCodexMessagesSnapshot(params: {
|
||||
: params.commentaryMessages.map(({ itemId, message }) =>
|
||||
attachCodexMirrorIdentity(message, `${params.turnId}:commentary:${itemId}`),
|
||||
);
|
||||
const visibleWorkMessages = [...commentaryMessages, ...params.toolMessages].toSorted(
|
||||
const asyncMessages = params.asyncMessages.map(({ itemId, message }) =>
|
||||
attachCodexMirrorIdentity(message, `${params.turnId}:async:${itemId}`),
|
||||
);
|
||||
const visibleWorkMessages = [
|
||||
...commentaryMessages,
|
||||
...asyncMessages,
|
||||
...params.toolMessages,
|
||||
].toSorted(
|
||||
(left, right) =>
|
||||
(asDateTimestampMs(left.timestamp) ?? 0) - (asDateTimestampMs(right.timestamp) ?? 0),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildEmptyToolTelemetry,
|
||||
createParams,
|
||||
createProjector,
|
||||
forCurrentTurn,
|
||||
registerCodexEventProjectorTestLifecycle,
|
||||
TURN_ID,
|
||||
turnCompleted,
|
||||
} from "./event-projector.test-harness.js";
|
||||
import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js";
|
||||
|
||||
registerCodexEventProjectorTestLifecycle();
|
||||
|
||||
describe("CodexAppServerEventProjector async delivery", () => {
|
||||
it("persists async delivery once without selecting it as the final answer", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onBlockReply = vi.fn();
|
||||
const params = await createParams();
|
||||
const sessionId = expectDefined(params.sessionId, "Codex async delivery test session");
|
||||
const storePath = `${params.workspaceDir}/openclaw-agent.sqlite`;
|
||||
params.sessionKey = "agent:main:session-1";
|
||||
const sessionTarget = {
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
};
|
||||
params.sessionTarget = sessionTarget;
|
||||
await upsertSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const projector = await createProjector(
|
||||
{
|
||||
...params,
|
||||
onAgentEvent,
|
||||
onBlockReply,
|
||||
},
|
||||
{
|
||||
onAsyncDelivery: async (delivery) => {
|
||||
return await codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({
|
||||
params: { ...params, onAgentEvent, onBlockReply },
|
||||
cwd: params.workspaceDir,
|
||||
threadId: "thread-1",
|
||||
turnId: TURN_ID,
|
||||
...delivery,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "terminal-answer",
|
||||
phase: "final_answer",
|
||||
text: "Finished.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const asyncCompletion = forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "async-update",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
text: "Background agent update.",
|
||||
},
|
||||
});
|
||||
await projector.handleNotification(asyncCompletion);
|
||||
expect(onBlockReply).toHaveBeenCalledOnce();
|
||||
expect(onBlockReply).toHaveBeenCalledWith(
|
||||
{ text: "Background agent update." },
|
||||
{
|
||||
deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-update`,
|
||||
},
|
||||
);
|
||||
await projector.handleNotification(asyncCompletion);
|
||||
expect(onBlockReply).toHaveBeenCalledOnce();
|
||||
await projector.handleNotification(
|
||||
turnCompleted([
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "async-update",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
text: "Background agent update.",
|
||||
},
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "terminal-answer",
|
||||
phase: "final_answer",
|
||||
text: "Finished.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(onBlockReply).toHaveBeenCalledOnce();
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
expect(result.assistantTexts).toEqual(["Finished."]);
|
||||
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "Finished." }]);
|
||||
const asyncMessages = result.messagesSnapshot.filter(
|
||||
(message) =>
|
||||
(message as { openclawAsyncDelivery?: { itemId?: unknown } }).openclawAsyncDelivery
|
||||
?.itemId === "async-update",
|
||||
);
|
||||
expect(asyncMessages).toHaveLength(1);
|
||||
expect(asyncMessages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Background agent update." }],
|
||||
openclawAsyncDelivery: { itemId: "async-update" },
|
||||
__openclaw: { mirrorIdentity: `${TURN_ID}:async:async-update` },
|
||||
});
|
||||
const transcriptMessages = (await readSessionTranscriptEvents(sessionTarget))
|
||||
.map((event) => (event as { message?: unknown }).message)
|
||||
.filter((message): message is Record<string, unknown> => Boolean(message));
|
||||
expect(
|
||||
transcriptMessages.filter(
|
||||
(message) =>
|
||||
(message.openclawAsyncDelivery as { itemId?: unknown } | undefined)?.itemId ===
|
||||
"async-update",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter(
|
||||
(event) =>
|
||||
event.stream === "item" &&
|
||||
event.data.itemId === "async-update" &&
|
||||
event.data.kind === "answer_candidate",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles sessionless async delivery once across completion and terminal replay", async () => {
|
||||
const params = await createParams();
|
||||
const onBlockReply = vi.fn();
|
||||
const projector = await createProjector(
|
||||
{ ...params, onBlockReply },
|
||||
{
|
||||
onAsyncDelivery: (delivery) =>
|
||||
codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({
|
||||
params: { ...params, onBlockReply },
|
||||
cwd: params.workspaceDir,
|
||||
threadId: "thread-1",
|
||||
turnId: TURN_ID,
|
||||
...delivery,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const asyncItem = {
|
||||
type: "agentMessage" as const,
|
||||
id: "async-sessionless",
|
||||
phase: "final_answer",
|
||||
delivery: "async" as const,
|
||||
text: "Sessionless background update.",
|
||||
};
|
||||
const completion = forCurrentTurn("item/completed", { item: asyncItem });
|
||||
|
||||
await projector.handleNotification(completion);
|
||||
await projector.handleNotification(completion);
|
||||
await projector.handleNotification(
|
||||
turnCompleted([
|
||||
asyncItem,
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "terminal-sessionless",
|
||||
phase: "final_answer",
|
||||
text: "Sessionless final.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledOnce();
|
||||
expect(onBlockReply).toHaveBeenCalledWith(
|
||||
{ text: "Sessionless background update." },
|
||||
{
|
||||
deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-sessionless`,
|
||||
},
|
||||
);
|
||||
expect(projector.buildResult(buildEmptyToolTelemetry()).assistantTexts).toEqual([
|
||||
"Sessionless final.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries unsettled sessionless async delivery from the terminal snapshot", async () => {
|
||||
const params = await createParams();
|
||||
const onBlockReply = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("channel unavailable"))
|
||||
.mockResolvedValue(undefined);
|
||||
const projector = await createProjector(
|
||||
{ ...params, onBlockReply },
|
||||
{
|
||||
onAsyncDelivery: (delivery) =>
|
||||
codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({
|
||||
params: { ...params, onBlockReply },
|
||||
cwd: params.workspaceDir,
|
||||
threadId: "thread-1",
|
||||
turnId: TURN_ID,
|
||||
...delivery,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const asyncItem = {
|
||||
type: "agentMessage" as const,
|
||||
id: "async-retry",
|
||||
phase: "final_answer",
|
||||
delivery: "async" as const,
|
||||
text: "Retry this background update.",
|
||||
};
|
||||
const completed = turnCompleted([
|
||||
asyncItem,
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "terminal-retry",
|
||||
phase: "final_answer",
|
||||
text: "Retry final.",
|
||||
},
|
||||
]);
|
||||
|
||||
await projector.handleNotification(forCurrentTurn("item/completed", { item: asyncItem }));
|
||||
await projector.handleNotification(completed);
|
||||
await projector.handleNotification(completed);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||
expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]);
|
||||
expect(onBlockReply).toHaveBeenCalledWith(
|
||||
{ text: "Retry this background update." },
|
||||
{
|
||||
deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-retry`,
|
||||
},
|
||||
);
|
||||
expect(projector.buildResult(buildEmptyToolTelemetry()).assistantTexts).toEqual([
|
||||
"Retry final.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains async delivery across reconstructed turn snapshots", async () => {
|
||||
const completed = turnCompleted([
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "async-reconnect",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
text: "Delivered while the client was reconnecting.",
|
||||
},
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "terminal-reconnect",
|
||||
phase: "final_answer",
|
||||
text: "Reconnect complete.",
|
||||
},
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const onAsyncDelivery = vi.fn().mockResolvedValue("settled");
|
||||
const projector = await createProjector(undefined, { onAsyncDelivery });
|
||||
await projector.handleNotification(completed);
|
||||
expect(onAsyncDelivery).toHaveBeenCalledOnce();
|
||||
expect(onAsyncDelivery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
itemId: "async-reconnect",
|
||||
text: "Delivered while the client was reconnecting.",
|
||||
}),
|
||||
);
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
|
||||
expect(result.assistantTexts).toEqual(["Reconnect complete."]);
|
||||
expect(
|
||||
result.messagesSnapshot.filter(
|
||||
(message) =>
|
||||
(message as { openclawAsyncDelivery?: { itemId?: unknown } }).openclawAsyncDelivery
|
||||
?.itemId === "async-reconnect",
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Delivered while the client was reconnecting." }],
|
||||
__openclaw: { mirrorIdentity: `${TURN_ID}:async:async-reconnect` },
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
THREAD_ID,
|
||||
TURN_ID,
|
||||
createParams,
|
||||
createProjector,
|
||||
buildEmptyToolTelemetry,
|
||||
@@ -84,6 +85,41 @@ describe("CodexAppServerEventProjector reasoning and guardian projection", () =>
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("routes strict review requirements to the human-visible guardian lane", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/autoApprovalReview/started", {
|
||||
reviewId: "review-strict",
|
||||
targetItemId: "cmd-strict",
|
||||
review: { status: "inProgress" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("autoApprovalReview/strictReviewRequired", {
|
||||
startedAtMs: 1_787_273_600_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
findAgentEvent(onAgentEvent, {
|
||||
stream: "codex_app_server.guardian",
|
||||
phase: "strict_review_required",
|
||||
}).data,
|
||||
).toMatchObject({
|
||||
method: "autoApprovalReview/strictReviewRequired",
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
reviewId: "review-strict",
|
||||
targetItemId: "cmd-strict",
|
||||
startedAtMs: 1_787_273_600_000,
|
||||
});
|
||||
expect(
|
||||
projector.buildResult(buildEmptyToolTelemetry()).didSendDeterministicApprovalPrompt,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("projects thread-scoped guardian warnings", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
@@ -202,6 +202,7 @@ describe("CodexAppServerEventProjector terminal errors", () => {
|
||||
{ codexErrorInfo: "serverOverloaded", expected: true },
|
||||
{ codexErrorInfo: "usageLimitExceeded", expected: false },
|
||||
{ codexErrorInfo: "unauthorized", expected: false },
|
||||
{ codexErrorInfo: "misalignmentPolicyViolation", expected: false },
|
||||
])(
|
||||
"projects $codexErrorInfo terminal error recovery eligibility as $expected",
|
||||
async ({ codexErrorInfo, expected }) => {
|
||||
|
||||
@@ -65,6 +65,7 @@ export class CodexAppServerEventProjector {
|
||||
private readonly reasoningProjection: CodexReasoningProjection;
|
||||
private readonly activeItemIds = new Set<string>();
|
||||
private readonly completedItemIds = new Set<string>();
|
||||
private readonly settledAsyncDeliveryItemIds = new Set<string>();
|
||||
private readonly activeCompactionItemIds = new Set<string>();
|
||||
private readonly terminalPresentationClearedItemIds = new Set<string>();
|
||||
private readonly nativeToolOutcomeOrdinals = new Map<string, number>();
|
||||
@@ -255,6 +256,9 @@ export class CodexAppServerEventProjector {
|
||||
case "item/autoApprovalReview/completed":
|
||||
this.eventProjection.handleGuardianReview(notification.method, params);
|
||||
break;
|
||||
case "autoApprovalReview/strictReviewRequired":
|
||||
this.eventProjection.handleStrictReviewRequired(params);
|
||||
break;
|
||||
case "guardianWarning":
|
||||
this.eventProjection.handleGuardianWarning(params);
|
||||
break;
|
||||
@@ -448,7 +452,14 @@ export class CodexAppServerEventProjector {
|
||||
this.activeItemIds.delete(itemId);
|
||||
this.completedItemIds.add(itemId);
|
||||
}
|
||||
this.assistantProjection.recordItemCompleted(item, itemId, this.activeItemIds);
|
||||
const asyncMessage = this.assistantProjection.recordItemCompleted(
|
||||
item,
|
||||
itemId,
|
||||
this.activeItemIds,
|
||||
);
|
||||
if (asyncMessage) {
|
||||
await this.deliverAsyncMessage(asyncMessage);
|
||||
}
|
||||
this.reasoningProjection.recordItem(item);
|
||||
await this.generatedMediaProjection.recordNative(item);
|
||||
if (item?.type === "contextCompaction" && itemId) {
|
||||
@@ -536,7 +547,10 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
for (const item of turnItems) {
|
||||
this.diagnostics.warnUnknownItemStatus(item);
|
||||
this.assistantProjection.recordSnapshotItem(item);
|
||||
const asyncMessage = this.assistantProjection.recordSnapshotItem(item);
|
||||
if (asyncMessage) {
|
||||
await this.deliverAsyncMessage(asyncMessage);
|
||||
}
|
||||
this.reasoningProjection.recordItem(item);
|
||||
await this.generatedMediaProjection.recordNative(item);
|
||||
this.toolProgressProjection.recordToolMeta(item);
|
||||
@@ -553,6 +567,22 @@ export class CodexAppServerEventProjector {
|
||||
await this.reasoningProjection.maybeEndReasoning();
|
||||
}
|
||||
|
||||
private async deliverAsyncMessage(delivery: {
|
||||
itemId: string;
|
||||
message: Parameters<
|
||||
NonNullable<CodexAppServerEventProjectorOptions["onAsyncDelivery"]>
|
||||
>[0]["message"];
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
if (this.settledAsyncDeliveryItemIds.has(delivery.itemId)) {
|
||||
return;
|
||||
}
|
||||
const settlement = await this.options.onAsyncDelivery?.(delivery);
|
||||
if (settlement === "settled") {
|
||||
this.settledAsyncDeliveryItemIds.add(delivery.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): Promise<void> {
|
||||
if (
|
||||
!shouldSynthesizeToolProgressForItem(item) ||
|
||||
|
||||
@@ -50,6 +50,7 @@ const validModelListEntry = {
|
||||
hidden: false,
|
||||
isDefault: false,
|
||||
defaultReasoningEffort: "medium",
|
||||
multiAgentVersion: "v2",
|
||||
supportedReasoningEfforts: [],
|
||||
};
|
||||
|
||||
@@ -123,12 +124,31 @@ describe("listCodexAppServerModels", () => {
|
||||
inputModalities: ["text", "image"],
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: "medium",
|
||||
multiAgentVersion: "v2",
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit null while omitting an absent multi-agent version", () => {
|
||||
expect(
|
||||
readModelListResult({
|
||||
data: [{ ...validModelListEntry, multiAgentVersion: null }],
|
||||
}).models[0],
|
||||
).toHaveProperty("multiAgentVersion", null);
|
||||
expect(
|
||||
readModelListResult({
|
||||
data: [
|
||||
{
|
||||
...validModelListEntry,
|
||||
multiAgentVersion: undefined,
|
||||
},
|
||||
],
|
||||
}).models[0],
|
||||
).not.toHaveProperty("multiAgentVersion");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "missing model data", response: {} },
|
||||
{ label: "non-array model data", response: { data: {} } },
|
||||
@@ -149,7 +169,7 @@ describe("listCodexAppServerModels", () => {
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: initialize.id,
|
||||
result: { userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
|
||||
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
|
||||
@@ -170,7 +190,7 @@ describe("listCodexAppServerModels", () => {
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: initialize.id,
|
||||
result: { userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
|
||||
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
|
||||
@@ -195,6 +215,7 @@ describe("listCodexAppServerModels", () => {
|
||||
{ reasoningEffort: "xhigh", description: "deep" },
|
||||
],
|
||||
defaultReasoningEffort: "medium",
|
||||
multiAgentVersion: "v2",
|
||||
supportsPersonality: false,
|
||||
additionalSpeedTiers: [],
|
||||
isDefault: true,
|
||||
@@ -215,6 +236,7 @@ describe("listCodexAppServerModels", () => {
|
||||
inputModalities: ["text", "image"],
|
||||
supportedReasoningEfforts: ["low", "xhigh"],
|
||||
defaultReasoningEffort: "medium",
|
||||
multiAgentVersion: "v2",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
@@ -232,7 +254,7 @@ describe("listCodexAppServerModels", () => {
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: initialize.id,
|
||||
result: { userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
|
||||
const firstList = JSON.parse(harness.writes[2] ?? "{}") as {
|
||||
@@ -312,7 +334,7 @@ describe("listCodexAppServerModels", () => {
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: initialize.id,
|
||||
result: { userAgent: "openclaw/0.147.0 (macOS; test)" },
|
||||
result: { userAgent: "openclaw/0.149.0 (macOS; test)" },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
|
||||
const firstList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
|
||||
|
||||
@@ -23,6 +23,7 @@ type CodexAppServerModel = {
|
||||
inputModalities: string[];
|
||||
supportedReasoningEfforts: string[];
|
||||
defaultReasoningEffort?: string;
|
||||
multiAgentVersion?: "disabled" | "v1" | "v2" | null;
|
||||
};
|
||||
|
||||
/** One page of Codex app-server model metadata plus optional pagination state. */
|
||||
@@ -168,6 +169,9 @@ function readCodexModel(value: CodexModel): CodexAppServerModel {
|
||||
...(normalizeOptionalString(value.defaultReasoningEffort)
|
||||
? { defaultReasoningEffort: normalizeOptionalString(value.defaultReasoningEffort) }
|
||||
: {}),
|
||||
...(value.multiAgentVersion !== undefined
|
||||
? { multiAgentVersion: value.multiAgentVersion }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("Codex native MCP Apps", () => {
|
||||
}
|
||||
if (method === "mcpServer/resource/read") {
|
||||
return {
|
||||
originCallId: params.originCallId,
|
||||
contents: [
|
||||
{
|
||||
uri: params.uri,
|
||||
@@ -80,11 +81,73 @@ describe("Codex native MCP Apps", () => {
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("mcpServer/resource/read", {
|
||||
threadId: "thread-1",
|
||||
originCallId: "call-options",
|
||||
server: "sample",
|
||||
uri: "ui://sample/options.html",
|
||||
connectorId: "sample",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "a different", responseOriginCallId: "call-other" },
|
||||
{ label: "no", responseOriginCallId: undefined },
|
||||
{ label: "a null", responseOriginCallId: null },
|
||||
])(
|
||||
"omits the app preview when Codex returns $label MCP origin call",
|
||||
async ({ responseOriginCallId }) => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "mcpServerStatus/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
name: "sample",
|
||||
tools: { show_options: { description: "Show options", inputSchema: {} } },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "mcpServer/resource/read") {
|
||||
return {
|
||||
...(responseOriginCallId !== undefined ? { originCallId: responseOriginCallId } : {}),
|
||||
contents: [
|
||||
{
|
||||
uri: "ui://sample/options.html",
|
||||
mimeType: "text/html;profile=mcp-app",
|
||||
text: "<html><body>Sample</body></html>",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
const prepare = createCodexNativeMcpAppResultDetailsPreparer({
|
||||
client: { request, getInstanceId: () => "client-1" } as unknown as CodexAppServerClient,
|
||||
threadId: "thread-1",
|
||||
attempt: createAttempt(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepare?.({
|
||||
id: "call-options",
|
||||
type: "mcpToolCall",
|
||||
server: "sample",
|
||||
tool: "show_options",
|
||||
status: "completed",
|
||||
appContext: { connectorId: "sample", resourceUri: "ui://sample/options.html" },
|
||||
arguments: {},
|
||||
result: { content: [{ type: "text", text: "Found options." }] },
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(request).toHaveBeenCalledWith("mcpServer/resource/read", {
|
||||
threadId: "thread-1",
|
||||
originCallId: "call-options",
|
||||
server: "sample",
|
||||
uri: "ui://sample/options.html",
|
||||
connectorId: "sample",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prepare native app views unless MCP Apps are enabled", () => {
|
||||
expect(
|
||||
createCodexNativeMcpAppResultDetailsPreparer({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getCodexAppServerClientInstanceId, type CodexAppServerClient } from "./client.js";
|
||||
import type { ResourceReadResult } from "./protocol-mcp.js";
|
||||
import type { CodexMcpServerStatus, CodexThreadItem, JsonObject, JsonValue } from "./protocol.js";
|
||||
import { retainSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
|
||||
|
||||
@@ -27,6 +28,10 @@ function readMcpAppResourceUri(item: CodexThreadItem): string | undefined {
|
||||
return uri?.startsWith("ui://") ? uri : undefined;
|
||||
}
|
||||
|
||||
function readMcpAppConnectorId(item: CodexThreadItem): string | undefined {
|
||||
return normalizeOptionalString(asOptionalRecord(item.appContext)?.connectorId);
|
||||
}
|
||||
|
||||
function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | undefined {
|
||||
const result = asOptionalRecord(item.result);
|
||||
if (!result || !Array.isArray(result.content)) {
|
||||
@@ -56,6 +61,8 @@ function createNativeMcpRuntime(params: {
|
||||
client: CodexAppServerClient;
|
||||
threadId: string;
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
originCallId: string;
|
||||
connectorId?: string;
|
||||
}): SessionMcpRuntime {
|
||||
// App interactions must stay on the thread-owned Codex MCP connection; opening
|
||||
// a second client here would lose server-local state between render and click.
|
||||
@@ -131,11 +138,16 @@ function createNativeMcpRuntime(params: {
|
||||
return { tools: status ? statusTools(status) : [] } as never;
|
||||
},
|
||||
readResource: async (serverName, uri) =>
|
||||
await params.client.request("mcpServer/resource/read", {
|
||||
threadId: params.threadId,
|
||||
server: serverName,
|
||||
uri,
|
||||
}),
|
||||
await readCorrelatedMcpResource(
|
||||
params.originCallId,
|
||||
params.client.request("mcpServer/resource/read", {
|
||||
threadId: params.threadId,
|
||||
originCallId: params.originCallId,
|
||||
server: serverName,
|
||||
uri,
|
||||
...(params.connectorId ? { connectorId: params.connectorId } : {}),
|
||||
}),
|
||||
),
|
||||
listResources: async (serverName) => {
|
||||
const status = (await loadStatuses()).find((entry) => entry.name === serverName);
|
||||
return { resources: status?.resources ?? [] };
|
||||
@@ -149,6 +161,19 @@ function createNativeMcpRuntime(params: {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function readCorrelatedMcpResource(
|
||||
originCallId: string,
|
||||
responsePromise: Promise<ResourceReadResult>,
|
||||
): Promise<ResourceReadResult> {
|
||||
const response = await responsePromise;
|
||||
if (response.originCallId !== originCallId) {
|
||||
throw new Error(
|
||||
`Codex MCP resource response originCallId mismatch: expected ${originCallId}, received ${response.originCallId}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function createCodexNativeMcpAppResultDetailsPreparer(params: {
|
||||
client: CodexAppServerClient;
|
||||
threadId: string;
|
||||
@@ -157,15 +182,20 @@ export function createCodexNativeMcpAppResultDetailsPreparer(params: {
|
||||
if (params.attempt.config?.mcp?.apps?.enabled !== true) {
|
||||
return undefined;
|
||||
}
|
||||
const runtime = createNativeMcpRuntime(params);
|
||||
return async (item) => {
|
||||
const serverName = normalizeOptionalString(item.server);
|
||||
const toolName = normalizeOptionalString(item.tool);
|
||||
const uiResourceUri = readMcpAppResourceUri(item);
|
||||
const connectorId = readMcpAppConnectorId(item);
|
||||
const toolResult = readMcpToolResult(item);
|
||||
if (!serverName || !toolName || !uiResourceUri || !toolResult) {
|
||||
return undefined;
|
||||
}
|
||||
const runtime = createNativeMcpRuntime({
|
||||
...params,
|
||||
originCallId: item.id,
|
||||
...(connectorId ? { connectorId } : {}),
|
||||
});
|
||||
const allowedAppToolNames = new Set(
|
||||
(await runtime.getCatalog()).tools
|
||||
.filter((tool) => tool.serverName === serverName)
|
||||
|
||||
@@ -27,6 +27,7 @@ export type CodexPluginThreadAppAdmissionDiagnostic = {
|
||||
type CodexPluginThreadAppAdmissionParams = {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
threadId?: string;
|
||||
appCacheKey: string;
|
||||
nowMs?: number;
|
||||
};
|
||||
@@ -37,6 +38,27 @@ export type CodexPluginThreadAppAdmissionConfig = {
|
||||
layers: readonly JsonObject[];
|
||||
};
|
||||
|
||||
export function resolveCodexPluginThreadAppCacheKey(params: {
|
||||
appCacheKey: string;
|
||||
threadId?: string;
|
||||
}): string {
|
||||
return params.threadId
|
||||
? `${params.appCacheKey}:thread:${encodeURIComponent(params.threadId)}`
|
||||
: params.appCacheKey;
|
||||
}
|
||||
|
||||
function createCodexPluginThreadAppInventoryRequest(
|
||||
params: CodexPluginThreadAppAdmissionParams,
|
||||
): CodexAppInventoryRequest {
|
||||
return async (method, requestParams) =>
|
||||
(await params.request(
|
||||
method,
|
||||
(method === "app/installed" || method === "app/read") && params.threadId
|
||||
? { ...requestParams, threadId: params.threadId }
|
||||
: requestParams,
|
||||
)) as CodexAppServerRequestResult<typeof method>;
|
||||
}
|
||||
|
||||
export async function refreshCodexPluginAppInventory(
|
||||
params: CodexPluginThreadAppAdmissionParams,
|
||||
appCache: CodexAppInventoryCache,
|
||||
@@ -45,11 +67,10 @@ export async function refreshCodexPluginAppInventory(
|
||||
if (!params.appCacheKey) {
|
||||
return undefined;
|
||||
}
|
||||
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
||||
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
|
||||
const request = createCodexPluginThreadAppInventoryRequest(params);
|
||||
try {
|
||||
return await appCache.refreshNow({
|
||||
key: params.appCacheKey,
|
||||
key: resolveCodexPluginThreadAppCacheKey(params),
|
||||
request,
|
||||
nowMs: params.nowMs,
|
||||
forceRefetch: options.forceRefetch,
|
||||
@@ -121,10 +142,9 @@ export async function readCodexThreadAdmissibleAccountApps(
|
||||
}> {
|
||||
// Account-wide policy must use a complete snapshot; a targeted plugin read
|
||||
// cannot establish which other account apps are authorized for this thread.
|
||||
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
||||
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
|
||||
const request = createCodexPluginThreadAppInventoryRequest(params);
|
||||
const cachedInventory = appCache.read({
|
||||
key: params.appCacheKey,
|
||||
key: resolveCodexPluginThreadAppCacheKey(params),
|
||||
request,
|
||||
nowMs: params.nowMs,
|
||||
suppressRefresh: true,
|
||||
|
||||
@@ -191,6 +191,7 @@ export function createCodexPluginThreadConfigStartupProvider(params: {
|
||||
build: async (buildOptions?: { threadId?: string }) => {
|
||||
const config = await buildCodexPluginThreadConfigWithinDeadline({
|
||||
...buildParams,
|
||||
threadId: buildOptions?.threadId,
|
||||
appCache: appCache ?? defaultCodexAppInventoryCache,
|
||||
metadataCache,
|
||||
failClosedOnTimeout: Boolean(params.scheduledRuntimeAuthority),
|
||||
|
||||
@@ -3069,6 +3069,94 @@ describe("Codex plugin thread config", () => {
|
||||
expect(timeoutMs).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it("evaluates app metadata and effective config against the resumed thread", async () => {
|
||||
const installedStates: Array<{ threadId?: string; enabled: boolean; callable: boolean }> = [];
|
||||
const request = vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === "plugin/installed") {
|
||||
return pluginInstalled([
|
||||
pluginSummary("google-calendar", { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
|
||||
}
|
||||
if (method === "app/installed" || method === "app/read") {
|
||||
const isResumedThread =
|
||||
(params as { threadId?: string } | undefined)?.threadId === "thread-149";
|
||||
if (method === "app/installed") {
|
||||
installedStates.push({
|
||||
...((params as { threadId?: string } | undefined)?.threadId
|
||||
? { threadId: (params as { threadId: string }).threadId }
|
||||
: {}),
|
||||
enabled: isResumedThread,
|
||||
callable: isResumedThread,
|
||||
});
|
||||
}
|
||||
return codexAppInventoryResponse(
|
||||
method,
|
||||
[appInfo("google-calendar-app", true, isResumedThread)],
|
||||
params as CodexAppServerRequestParams<typeof method>,
|
||||
{
|
||||
callableByAppId: {
|
||||
"google-calendar-app": isResumedThread,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
return { config: {}, layers: [] };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const buildConfig = (threadId?: string) =>
|
||||
createCodexPluginThreadConfigStartupProvider({
|
||||
inputFingerprint: undefined,
|
||||
enabledPluginConfigKeys: ["google-calendar"],
|
||||
policy: undefined,
|
||||
requestTimeoutMs: 1_000,
|
||||
signal: new AbortController().signal,
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
configCwd: "/workspace/project",
|
||||
appCache: new CodexAppInventoryCache(),
|
||||
appCacheKey: "runtime",
|
||||
metadataCache: new CodexPluginMetadataCache(),
|
||||
client: { request },
|
||||
}).build(threadId ? { threadId } : {});
|
||||
|
||||
const resumedConfig = await buildConfig("thread-149");
|
||||
const defaultConfig = await buildConfig();
|
||||
|
||||
expect(resumedConfig.configPatch?.apps).toHaveProperty("google-calendar-app");
|
||||
expect(defaultConfig.configPatch?.apps).toHaveProperty("google-calendar-app");
|
||||
expect(installedStates).toEqual([
|
||||
{ threadId: "thread-149", enabled: true, callable: true },
|
||||
{ enabled: false, callable: false },
|
||||
]);
|
||||
expect(request.mock.calls.find(([method]) => method === "app/installed")?.[1]).toEqual({
|
||||
forceRefresh: true,
|
||||
threadId: "thread-149",
|
||||
});
|
||||
expect(request.mock.calls.find(([method]) => method === "app/read")?.[1]).toEqual({
|
||||
appIds: ["google-calendar-app"],
|
||||
threadId: "thread-149",
|
||||
});
|
||||
expect(request.mock.calls.find(([method]) => method === "config/read")?.[1]).toEqual({
|
||||
includeLayers: true,
|
||||
cwd: "/workspace/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates an outer abort while waiting on coalesced metadata", async () => {
|
||||
const metadataCache = new CodexPluginMetadataCache();
|
||||
let release: ((response: v2.PluginInstalledResponse) => void) | undefined;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
readCodexConfigForAppAdmission,
|
||||
readCodexThreadAdmissibleAccountApps,
|
||||
refreshCodexPluginAppInventory,
|
||||
resolveCodexPluginThreadAppCacheKey,
|
||||
resolveCodexExplicitAppEnablement,
|
||||
resolveCodexPluginAppThreadAdmission,
|
||||
resolveCodexThreadConfigAppsForRecord,
|
||||
@@ -106,6 +107,7 @@ type BuildCodexPluginThreadConfigParams = {
|
||||
pluginConfig?: unknown;
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
threadId?: string;
|
||||
appCache?: CodexAppInventoryCache;
|
||||
appCacheKey: string;
|
||||
metadataCache?: CodexPluginMetadataCache;
|
||||
@@ -156,6 +158,16 @@ export async function buildCodexPluginThreadConfig(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
): Promise<CodexPluginThreadConfig> {
|
||||
const appCache = params.appCache ?? defaultCodexAppInventoryCache;
|
||||
const threadAppCacheKey = resolveCodexPluginThreadAppCacheKey(params);
|
||||
const threadRequest: CodexPluginRuntimeRequest = (method, requestParams) =>
|
||||
params.request(
|
||||
method,
|
||||
(method === "app/installed" || method === "app/read") &&
|
||||
params.threadId &&
|
||||
isJsonObject(requestParams)
|
||||
? { ...requestParams, threadId: params.threadId }
|
||||
: requestParams,
|
||||
);
|
||||
let inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
|
||||
pluginConfig: params.pluginConfig,
|
||||
appCacheKey: params.appCacheKey,
|
||||
@@ -174,9 +186,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
? await readCodexPluginInventory({
|
||||
pluginConfig: params.pluginConfig,
|
||||
policy,
|
||||
request: params.request,
|
||||
request: threadRequest,
|
||||
appCache,
|
||||
appCacheKey: params.appCacheKey,
|
||||
appCacheKey: threadAppCacheKey,
|
||||
configCwd: params.configCwd,
|
||||
metadataCache: params.metadataCache,
|
||||
nowMs: params.nowMs,
|
||||
@@ -198,9 +210,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
inventory = await readCodexPluginInventory({
|
||||
pluginConfig: params.pluginConfig,
|
||||
policy,
|
||||
request: params.request,
|
||||
request: threadRequest,
|
||||
appCache,
|
||||
appCacheKey: params.appCacheKey,
|
||||
appCacheKey: threadAppCacheKey,
|
||||
configCwd: params.configCwd,
|
||||
metadataCache: params.metadataCache,
|
||||
nowMs: params.nowMs,
|
||||
@@ -218,9 +230,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
}
|
||||
const activation = await ensureCodexPluginActivation({
|
||||
identity: record.policy,
|
||||
request: params.request,
|
||||
request: threadRequest,
|
||||
appCache,
|
||||
appCacheKey: params.appCacheKey,
|
||||
appCacheKey: threadAppCacheKey,
|
||||
configCwd: params.configCwd,
|
||||
metadataCache: params.metadataCache,
|
||||
deferAppInventoryRefresh: true,
|
||||
@@ -253,9 +265,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
inventory = await readCodexPluginInventory({
|
||||
pluginConfig: params.pluginConfig,
|
||||
policy,
|
||||
request: params.request,
|
||||
request: threadRequest,
|
||||
appCache,
|
||||
appCacheKey: params.appCacheKey,
|
||||
appCacheKey: threadAppCacheKey,
|
||||
configCwd: params.configCwd,
|
||||
metadataCache: params.metadataCache,
|
||||
nowMs: params.nowMs,
|
||||
@@ -274,9 +286,9 @@ export async function buildCodexPluginThreadConfig(
|
||||
inventory = await readCodexPluginInventory({
|
||||
pluginConfig: params.pluginConfig,
|
||||
policy,
|
||||
request: params.request,
|
||||
request: threadRequest,
|
||||
appCache,
|
||||
appCacheKey: params.appCacheKey,
|
||||
appCacheKey: threadAppCacheKey,
|
||||
configCwd: params.configCwd,
|
||||
metadataCache: params.metadataCache,
|
||||
nowMs: params.nowMs,
|
||||
|
||||
@@ -159,6 +159,7 @@ type CodexConnectorMetadata = {
|
||||
|
||||
export type CodexAppsReadParams = {
|
||||
appIds: string[];
|
||||
threadId?: string | null;
|
||||
includeTools?: boolean;
|
||||
};
|
||||
|
||||
@@ -214,6 +215,11 @@ export type CodexConfigReadResponse = {
|
||||
layers?: JsonValue[] | null;
|
||||
};
|
||||
|
||||
export type CodexConfigReadParams = {
|
||||
includeLayers?: boolean;
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
type CodexConfigMergeStrategy = "replace" | "upsert";
|
||||
|
||||
export type CodexConfigEdit = {
|
||||
@@ -235,6 +241,7 @@ export type CodexConfigBatchWriteParams = {
|
||||
};
|
||||
|
||||
type CodexConfigLayerSource =
|
||||
| { type: "packagedDefaults"; file: string }
|
||||
| { type: "mdm"; domain: string; key: string }
|
||||
| { type: "system"; file: string }
|
||||
| { type: "enterpriseManaged"; id: string; name: string }
|
||||
|
||||
+128
@@ -92,6 +92,12 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AgentMessageDelivery": {
|
||||
"enum": [
|
||||
"async"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -299,6 +305,7 @@
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"misalignmentPolicyViolation",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
@@ -623,6 +630,37 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ImageGenerationFailure": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"limitId": {
|
||||
"type": "string"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"usageLimitExceeded"
|
||||
],
|
||||
"title": "UsageLimitExceededImageGenerationFailureType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limitId",
|
||||
"type"
|
||||
],
|
||||
"title": "UsageLimitExceededImageGenerationFailure",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"InputModality": {
|
||||
"description": "Canonical user-input modality tags advertised by a model.",
|
||||
"oneOf": [
|
||||
@@ -853,6 +891,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"multiAgentVersion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MultiAgentVersion"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Multi-agent runtime declared by this model, when available."
|
||||
},
|
||||
"serviceTiers": {
|
||||
"default": [],
|
||||
"items": {
|
||||
@@ -946,6 +995,14 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"retirementAt": {
|
||||
"description": "Informational Unix timestamp for this upgrade's scheduled retirement, if known.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"upgradeCopy": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -983,6 +1040,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MultiAgentVersion": {
|
||||
"description": "Multi-agent runtime supported by a model.",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"v1",
|
||||
"v2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
"restricted",
|
||||
@@ -1080,6 +1146,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -1465,6 +1533,13 @@
|
||||
"description": "Usually the first user message in the thread, if available.",
|
||||
"type": "string"
|
||||
},
|
||||
"projectId": {
|
||||
"description": "Canonical project assignment owned by app-server, if any.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"recencyAt": {
|
||||
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
|
||||
"format": "int64",
|
||||
@@ -1546,6 +1621,7 @@
|
||||
"id",
|
||||
"modelProvider",
|
||||
"preview",
|
||||
"projectId",
|
||||
"sessionId",
|
||||
"source",
|
||||
"status",
|
||||
@@ -1639,6 +1715,17 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"delivery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentMessageDelivery"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2236,6 +2323,17 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"failure": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageGenerationFailure"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2358,6 +2456,18 @@
|
||||
"ThreadSection": {
|
||||
"description": "An independently persisted, user-visible thread section.",
|
||||
"properties": {
|
||||
"appearance": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ThreadSectionAppearance"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Optional appearance synchronized across clients."
|
||||
},
|
||||
"id": {
|
||||
"description": "Opaque UUIDv7 identity that remains stable when the section is renamed.",
|
||||
"type": "string"
|
||||
@@ -2373,6 +2483,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadSectionAppearance": {
|
||||
"description": "Extensible visual presentation for a custom thread section.",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"itemsBackwardsCursor": {
|
||||
"default": null,
|
||||
"description": "Opaque head cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head item.",
|
||||
"description": "Opaque cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the item identified by the cursor.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"turnsBackwardsCursor": {
|
||||
"default": null,
|
||||
"description": "Opaque head cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head turn.",
|
||||
"description": "Opaque cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the turn identified by the cursor.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
@@ -24,8 +24,10 @@ export type CodexListMcpServerStatusResponse = {
|
||||
|
||||
export type ResourceReadParams = {
|
||||
threadId?: string | null;
|
||||
originCallId?: string | null;
|
||||
server: string;
|
||||
uri: string;
|
||||
connectorId?: string | null;
|
||||
};
|
||||
|
||||
export type ToolCallParams = {
|
||||
@@ -36,7 +38,14 @@ export type ToolCallParams = {
|
||||
_meta?: JsonValue;
|
||||
};
|
||||
|
||||
export type ResourceReadResult = { contents: JsonValue[] };
|
||||
type CodexMcpResourceContent =
|
||||
| { uri: string; mimeType?: string; text: string; _meta?: unknown }
|
||||
| { uri: string; mimeType?: string; blob: string; _meta?: unknown };
|
||||
|
||||
export type ResourceReadResult = {
|
||||
contents: CodexMcpResourceContent[];
|
||||
originCallId?: string | null;
|
||||
};
|
||||
|
||||
export type ToolCallResult = {
|
||||
content: JsonValue[];
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeMinimalThread(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "thread-1",
|
||||
sessionId: "session-1",
|
||||
projectId: null,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
createdAt: 1715299200,
|
||||
updatedAt: 1715299200,
|
||||
@@ -154,6 +155,20 @@ describe("assertCodexModelListResponse", () => {
|
||||
});
|
||||
|
||||
describe("readCodexTurn", () => {
|
||||
it("normalizes omitted agent-message delivery to the synchronous default", () => {
|
||||
const turn = readCodexTurn({
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ id: "message-1", type: "agentMessage", text: "done" }],
|
||||
});
|
||||
|
||||
expect(turn?.items[0]).toMatchObject({
|
||||
id: "message-1",
|
||||
type: "agentMessage",
|
||||
delivery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge defaults from unrelated thread item union branches", () => {
|
||||
const turn = readCodexTurn({
|
||||
id: "turn-1",
|
||||
|
||||
@@ -441,7 +441,7 @@ function normalizeThreadItem(value: unknown): unknown {
|
||||
const item = value as { type?: unknown };
|
||||
switch (item.type) {
|
||||
case "agentMessage":
|
||||
return { phase: null, memoryCitation: null, ...value };
|
||||
return { phase: null, delivery: null, memoryCitation: null, ...value };
|
||||
case "plan":
|
||||
return { text: "", ...value };
|
||||
case "reasoning":
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CodexAppsReadParams,
|
||||
CodexAppsReadResponse,
|
||||
CodexConfigBatchWriteParams,
|
||||
CodexConfigReadParams,
|
||||
CodexConfigReadResponse,
|
||||
CodexConfigRequirementsReadResponse,
|
||||
CodexConfigValueWriteParams,
|
||||
@@ -170,6 +171,7 @@ export type CodexTurnEnvironmentParams = JsonObject & {
|
||||
export type CodexThreadStartParams = JsonObject & {
|
||||
input?: CodexUserInput[];
|
||||
cwd?: string;
|
||||
projectId?: string | null;
|
||||
runtimeWorkspaceRoots?: string[] | null;
|
||||
model?: string;
|
||||
modelProvider?: string | null;
|
||||
@@ -426,6 +428,7 @@ export type CodexThread = {
|
||||
id: string;
|
||||
sessionId?: string;
|
||||
path?: string | null;
|
||||
projectId: string | null;
|
||||
historyMode?: "legacy" | "paginated";
|
||||
extra?: JsonObject | null;
|
||||
name?: string | null;
|
||||
@@ -499,16 +502,28 @@ export type CodexThreadItem = {
|
||||
durationMs?: number | null;
|
||||
aggregatedOutput: string | null;
|
||||
text: string;
|
||||
delivery?: "async" | null;
|
||||
contentItems?: CodexDynamicToolCallOutputContentItem[] | null;
|
||||
changes: Array<{ path: string; kind: string }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CodexServerNotification = {
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
type CodexStrictReviewRequiredNotification = {
|
||||
method: "autoApprovalReview/strictReviewRequired";
|
||||
params: JsonObject & {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
startedAtMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodexServerNotification =
|
||||
| CodexStrictReviewRequiredNotification
|
||||
| {
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
};
|
||||
|
||||
export type CodexDynamicToolCallParams = {
|
||||
namespace?: string | null;
|
||||
threadId: string;
|
||||
@@ -548,7 +563,7 @@ export type CodexDynamicToolCallOutputContentItem =
|
||||
export type CodexErrorNotification = {
|
||||
error: {
|
||||
message?: string;
|
||||
codexErrorInfo?: string | JsonObject | null;
|
||||
codexErrorInfo?: "misalignmentPolicyViolation" | (string & {}) | JsonObject | null;
|
||||
additionalDetails?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -571,6 +586,7 @@ export type CodexModel = {
|
||||
inputModalities: string[];
|
||||
supportedReasoningEfforts: CodexReasoningEffortOption[];
|
||||
defaultReasoningEffort?: string | null;
|
||||
multiAgentVersion?: "disabled" | "v1" | "v2" | null;
|
||||
};
|
||||
|
||||
export type CodexReasoningEffortOption = {
|
||||
@@ -641,6 +657,7 @@ type CodexAppServerRequestParamsOverride = {
|
||||
"app/read": CodexAppsReadParams;
|
||||
"command/exec": CodexCommandExecParams;
|
||||
"config/batchWrite": CodexConfigBatchWriteParams;
|
||||
"config/read": CodexConfigReadParams;
|
||||
"config/value/write": CodexConfigValueWriteParams;
|
||||
"environment/add": { environmentId: string; execServerUrl: string };
|
||||
"plugin/installed": CodexPluginInstalledParams;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { CodexAttemptNotificationController } from "./run-attempt-notificat
|
||||
import type { CodexAttemptResources } from "./run-attempt-resources.js";
|
||||
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
|
||||
import {
|
||||
codexTranscriptMirrorRuntime,
|
||||
createCodexAppServerUserMessagePersistenceNotifier,
|
||||
mirrorPromptAtTurnStartBestEffort,
|
||||
} from "./transcript-mirror.js";
|
||||
@@ -102,6 +103,15 @@ export async function activateCodexAttemptTurn(
|
||||
nativePostToolUseRelayEnabled:
|
||||
resourceState.nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
|
||||
resourceState.nativeHookRelay.shouldRelayEvent("post_tool_use"),
|
||||
onAsyncDelivery: async (delivery) => {
|
||||
return await codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({
|
||||
params: dynamicToolParams,
|
||||
cwd: effectiveCwd,
|
||||
threadId: resourceState.thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
...delivery,
|
||||
});
|
||||
},
|
||||
readRecentRateLimits: () => readRecentCodexRateLimits(resourceState.client),
|
||||
runAbortSignal: runAbortController.signal,
|
||||
remoteWorkspaceRoot: connection.appServer.remoteWorkspaceRoot,
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("prepareCodexAttemptConnection", () => {
|
||||
expect(resolveModelPolicy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not give OpenClaw ownership of an explicit operator approval policy", async () => {
|
||||
it("rejects the retired explicit untrusted approval policy with Doctor remediation", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
|
||||
);
|
||||
@@ -104,15 +104,17 @@ describe("prepareCodexAttemptConnection", () => {
|
||||
params.agentDir = path.join(tempDir, "agent");
|
||||
registerCodexTestSessionIdentity(sessionFile, params.sessionId, params.sessionKey);
|
||||
|
||||
const connection = await prepareCodexAttemptConnection({
|
||||
params,
|
||||
options: {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: { approvalPolicy: "untrusted" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.appServer.approvalPolicy).toBe("untrusted");
|
||||
await expect(
|
||||
prepareCodexAttemptConnection({
|
||||
params,
|
||||
options: {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: { approvalPolicy: "untrusted" } },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".',
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a workspace session mode override explicitly configured full exec", async () => {
|
||||
|
||||
@@ -78,7 +78,8 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -76,6 +76,21 @@ describe("Codex app-server binding store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not normalize untrusted persisted binding policy into live runtime policy", () => {
|
||||
expect(
|
||||
readCodexAppServerThreadBinding({
|
||||
threadId: "thread-untrusted-policy",
|
||||
cwd: "/repo",
|
||||
approvalPolicy: "untrusted",
|
||||
sandbox: "workspace-write",
|
||||
}),
|
||||
).toEqual({
|
||||
threadId: "thread-untrusted-policy",
|
||||
cwd: "/repo",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores domain data under the canonical session identity", async () => {
|
||||
const { state, values } = createStateStore();
|
||||
const store = createCodexAppServerBindingStore(state);
|
||||
|
||||
@@ -227,7 +227,7 @@ const threadBindingSchema = z
|
||||
approvalPolicy: z
|
||||
.preprocess(
|
||||
(value) => (value === "on-failure" ? "on-request" : value),
|
||||
z.enum(["never", "on-request", "untrusted"]).optional(),
|
||||
z.enum(["never", "on-request"]).optional(),
|
||||
)
|
||||
.catch(undefined),
|
||||
sandbox: z
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import { acquireCodexNativeConfigFence } from "./native-config-fence.js";
|
||||
import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
import { CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
|
||||
@@ -279,7 +279,7 @@ describe("shared Codex app-server client", () => {
|
||||
await sendInitializeResult(harness, "openclaw/0.117.9 (macOS; test)");
|
||||
|
||||
await expect(listPromise).rejects.toThrow(
|
||||
`Codex app-server ${CODEX_APP_SERVER_VERSION} is required`,
|
||||
`Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`,
|
||||
);
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
startSpy.mockRestore();
|
||||
@@ -345,7 +345,7 @@ describe("shared Codex app-server client", () => {
|
||||
const options = { config, startOptions, timeoutMs: 1_000 };
|
||||
|
||||
const firstAcquire = getLeasedSharedCodexAppServerClient(options);
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (Linux; test)");
|
||||
await expect(firstAcquire).resolves.toBe(first.client);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledOnce();
|
||||
@@ -365,7 +365,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
const replacementAcquire = getLeasedSharedCodexAppServerClient(options);
|
||||
await sendInitializeResult(replacement, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(replacement, "openclaw/0.149.0 (Linux; test)");
|
||||
await expect(replacementAcquire).resolves.toBe(replacement.client);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledTimes(3);
|
||||
@@ -416,7 +416,7 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(first).rejects.toThrow("codex app-server initialize aborted");
|
||||
expect(harness.stdinDestroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)");
|
||||
await expect(second).resolves.toBe(harness.client);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(harness.client)).toBe(true);
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe("shared Codex app-server client", () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const acquire = getLeasedSharedCodexAppServerClient({ timeoutMs: 1_000 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)");
|
||||
const client = await acquire;
|
||||
|
||||
const retained = retainSharedCodexAppServerClientByInstanceId(client.getInstanceId());
|
||||
@@ -439,7 +439,7 @@ describe("shared Codex app-server client", () => {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const options = { timeoutMs: 1_000 };
|
||||
const firstLease = getLeasedSharedCodexAppServerClient(options);
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)");
|
||||
const client = await firstLease;
|
||||
await expect(getLeasedSharedCodexAppServerClient(options)).resolves.toBe(client);
|
||||
const ownedLease = { client };
|
||||
@@ -480,8 +480,8 @@ describe("shared Codex app-server client", () => {
|
||||
const startOptions = configureManagedDesktopFallback();
|
||||
|
||||
const firstAcquire = getSharedCodexAppServerClient({ startOptions, timeoutMs: 1_000 });
|
||||
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
|
||||
await sendInitializeResult(pluginLocal, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(desktop, "openclaw/0.148.0 (macOS; test)");
|
||||
await sendInitializeResult(pluginLocal, "openclaw/0.149.0 (macOS; test)");
|
||||
const firstClient = await firstAcquire;
|
||||
|
||||
const secondClient = await getSharedCodexAppServerClient({ startOptions, timeoutMs: 1_000 });
|
||||
@@ -524,10 +524,10 @@ describe("shared Codex app-server client", () => {
|
||||
};
|
||||
|
||||
const firstAcquire = getSharedCodexAppServerClient(options);
|
||||
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
|
||||
await sendInitializeResult(desktop, "openclaw/0.148.0 (macOS; test)");
|
||||
await vi.waitFor(() => expect(fallback.writes.length).toBeGreaterThanOrEqual(1));
|
||||
const secondAcquire = getSharedCodexAppServerClient(options);
|
||||
await sendInitializeResult(fallback, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(fallback, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
const [firstClient, secondClient] = await Promise.all([firstAcquire, secondAcquire]);
|
||||
expect(secondClient).toBe(firstClient);
|
||||
@@ -555,13 +555,13 @@ describe("shared Codex app-server client", () => {
|
||||
};
|
||||
|
||||
const normalPromise = getLeasedSharedCodexAppServerClient({ startOptions });
|
||||
await sendInitializeResult(normal, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(normal, "openclaw/0.149.0 (Linux; test)");
|
||||
const normalClient = await normalPromise;
|
||||
const capturedPromise = getLeasedSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
runtimeArtifactMode: "capture",
|
||||
});
|
||||
await sendInitializeResult(captured, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(captured, "openclaw/0.149.0 (Linux; test)");
|
||||
const capturedClient = await capturedPromise;
|
||||
|
||||
expect(capturedClient).not.toBe(normalClient);
|
||||
@@ -613,7 +613,7 @@ describe("shared Codex app-server client", () => {
|
||||
runtimeArtifactMode: "capture",
|
||||
});
|
||||
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
|
||||
await sendInitializeResult(fallback, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(fallback, "openclaw/0.149.0 (macOS; test)");
|
||||
const client = await acquire;
|
||||
const { readCodexAppServerClientRuntimeArtifact, validateCodexAppServerRuntimeArtifact } =
|
||||
await import("./runtime-artifact.js");
|
||||
@@ -676,7 +676,7 @@ describe("shared Codex app-server client", () => {
|
||||
startOptions,
|
||||
agentDir,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
const client = await clientPromise;
|
||||
|
||||
expect(readCodexAppServerClientProcessIdentity(client)).toEqual({
|
||||
@@ -685,8 +685,8 @@ describe("shared Codex app-server client", () => {
|
||||
argsFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
commandSource: "resolved-managed",
|
||||
nativeCommand: "/cache/openclaw/codex.native",
|
||||
serverVersion: "0.147.0",
|
||||
userAgent: "openclaw/0.147.0 (macOS; test)",
|
||||
serverVersion: "0.149.0",
|
||||
userAgent: "openclaw/0.149.0 (macOS; test)",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
@@ -770,7 +770,7 @@ describe("shared Codex app-server client", () => {
|
||||
};
|
||||
|
||||
const clientPromise = createIsolatedCodexAppServerClient({ startOptions, agentDir });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
const client = await clientPromise;
|
||||
const fenceKey = resolveCodexNativeConfigFenceKey({ client });
|
||||
expect(fenceKey).toBeTypeOf("string");
|
||||
@@ -839,7 +839,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
@@ -872,7 +872,7 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(shortAcquire).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(longAcquire).resolves.toBe(harness.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -885,7 +885,7 @@ describe("shared Codex app-server client", () => {
|
||||
const releaseAuth = deferNextAuthProfileApplication();
|
||||
|
||||
const acquire = getSharedCodexAppServerClient({ timeoutMs: 100 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(acquire).rejects.toThrow("codex app-server authentication timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
@@ -899,7 +899,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const shortAcquire = getSharedCodexAppServerClient({ timeoutMs: 100 });
|
||||
const longAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(shortAcquire).rejects.toThrow("codex app-server authentication timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
@@ -928,7 +928,7 @@ describe("shared Codex app-server client", () => {
|
||||
abandonController.abort();
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await abandonedRejection;
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
@@ -984,7 +984,7 @@ describe("shared Codex app-server client", () => {
|
||||
const rejection = expect(clientPromise).rejects.toThrow(
|
||||
"codex app-server initialize timed out",
|
||||
);
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await rejection;
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
@@ -1000,7 +1000,7 @@ describe("shared Codex app-server client", () => {
|
||||
const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
now = 101;
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(mocks.applyCodexAppServerAuthProfile).not.toHaveBeenCalled();
|
||||
@@ -1015,7 +1015,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(harness);
|
||||
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
@@ -1042,7 +1042,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
authProfileStore,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({
|
||||
@@ -1101,7 +1101,7 @@ describe("shared Codex app-server client", () => {
|
||||
store: authProfileStore,
|
||||
},
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled();
|
||||
@@ -1195,7 +1195,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
preparedAuth: { kind: "profile", profileId: "openai:scoped", store: firstStore },
|
||||
});
|
||||
await sendInitializeResult(firstHarness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(firstHarness, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(firstPromise).resolves.toBe(firstHarness.client);
|
||||
|
||||
const secondPromise = getSharedCodexAppServerClient({
|
||||
@@ -1203,7 +1203,7 @@ describe("shared Codex app-server client", () => {
|
||||
preparedAuth: { kind: "profile", profileId: "openai:scoped", store: secondStore },
|
||||
});
|
||||
await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2));
|
||||
await sendInitializeResult(secondHarness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(secondHarness, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(secondPromise).resolves.toBe(secondHarness.client);
|
||||
|
||||
expect(resolvedCacheKeys).toEqual(["account:sha256:first", "account:sha256:second"]);
|
||||
@@ -1239,7 +1239,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
preparedAuth: { kind: "api-key", apiKey: "platform-key" },
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled();
|
||||
@@ -1288,7 +1288,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
preparedAuth: { kind: "api-key", apiKey: "first-platform-key" },
|
||||
});
|
||||
await sendInitializeResult(firstHarness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(firstHarness, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(firstPromise).resolves.toBe(firstHarness.client);
|
||||
|
||||
const secondPromise = getSharedCodexAppServerClient({
|
||||
@@ -1296,7 +1296,7 @@ describe("shared Codex app-server client", () => {
|
||||
preparedAuth: { kind: "api-key", apiKey: "second-platform-key" },
|
||||
});
|
||||
await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2));
|
||||
await sendInitializeResult(secondHarness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(secondHarness, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(secondPromise).resolves.toBe(secondHarness.client);
|
||||
|
||||
expect(cacheKeys).toEqual(["api_key:sha256:first", "api_key:sha256:second"]);
|
||||
@@ -1324,7 +1324,7 @@ describe("shared Codex app-server client", () => {
|
||||
authProfileId: "openai:persisted",
|
||||
agentDir: "/tmp/openclaw-persisted-agent",
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
const priorWriteCount = harness.writes.length;
|
||||
@@ -1362,7 +1362,7 @@ describe("shared Codex app-server client", () => {
|
||||
agentId: "research",
|
||||
config,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
@@ -1392,7 +1392,7 @@ describe("shared Codex app-server client", () => {
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
@@ -1410,7 +1410,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
config,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(harness);
|
||||
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
@@ -1437,7 +1437,7 @@ describe("shared Codex app-server client", () => {
|
||||
authProfileId: "openai:work",
|
||||
agentDir: "/tmp/openclaw-agent-nova",
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(harness);
|
||||
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
@@ -1461,7 +1461,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1469,7 +1469,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1488,7 +1488,7 @@ describe("shared Codex app-server client", () => {
|
||||
}));
|
||||
|
||||
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(harness);
|
||||
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
@@ -1553,7 +1553,7 @@ describe("shared Codex app-server client", () => {
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1568,7 +1568,7 @@ describe("shared Codex app-server client", () => {
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1591,7 +1591,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
authRequirement: "api-key",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1599,7 +1599,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
authRequirement: "api-key",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1621,7 +1621,7 @@ describe("shared Codex app-server client", () => {
|
||||
authProfileId: "openai:work",
|
||||
authRequirement: "api-key",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1630,7 +1630,7 @@ describe("shared Codex app-server client", () => {
|
||||
authProfileId: "openai:work",
|
||||
authRequirement: "subscription",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1685,7 +1685,7 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1703,7 +1703,7 @@ describe("shared Codex app-server client", () => {
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1711,7 +1711,7 @@ describe("shared Codex app-server client", () => {
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1729,7 +1729,7 @@ describe("shared Codex app-server client", () => {
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1748,7 +1748,7 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(activeRequest).rejects.toThrow("codex app-server client is closed");
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -1768,7 +1768,7 @@ describe("shared Codex app-server client", () => {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client);
|
||||
|
||||
const clientPromise = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)");
|
||||
const client = await clientPromise;
|
||||
const deliverCompletion = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
const taskRuntime = {
|
||||
@@ -1863,7 +1863,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(firstLease).resolves.toBe(first.client);
|
||||
await expect(secondLease).resolves.toBe(first.client);
|
||||
|
||||
@@ -1897,7 +1897,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const completedRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const siblingRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(completedRunLease).resolves.toBe(first.client);
|
||||
await expect(siblingRunLease).resolves.toBe(first.client);
|
||||
|
||||
@@ -1946,7 +1946,7 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(pendingLease).rejects.toThrow("codex app-server client is closed");
|
||||
|
||||
const freshLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(freshLease).resolves.toBe(second.client);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
@@ -1956,7 +1956,7 @@ describe("shared Codex app-server client", () => {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
|
||||
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(lease).resolves.toBe(first.client);
|
||||
|
||||
// Routine cleanup detaches gracefully; a later terminal-idle kill must
|
||||
@@ -1982,7 +1982,7 @@ describe("shared Codex app-server client", () => {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
|
||||
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await expect(lease).resolves.toBe(first.client);
|
||||
|
||||
// Routine cleanup (e.g. one-shot bundle-MCP) must not yank a healthy
|
||||
@@ -2002,7 +2002,7 @@ describe("shared Codex app-server client", () => {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client);
|
||||
|
||||
const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)");
|
||||
await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)");
|
||||
const client = await lease;
|
||||
const releaseRetain = retainSharedCodexAppServerClientIfCurrent(client);
|
||||
expect(releaseRetain).toBeTypeOf("function");
|
||||
@@ -2035,7 +2035,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -2043,7 +2043,7 @@ describe("shared Codex app-server client", () => {
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)");
|
||||
await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
@@ -2069,7 +2069,7 @@ describe("shared Codex app-server client", () => {
|
||||
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
|
||||
if (message.method === "initialize") {
|
||||
socket.send(
|
||||
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.147.0" } }),
|
||||
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.149.0" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +278,8 @@ function threadResult(threadId: string) {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp/workspace",
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -1284,7 +1284,14 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "config/read") {
|
||||
return {
|
||||
layers: [],
|
||||
layers: [
|
||||
{
|
||||
name: {
|
||||
type: "packagedDefaults",
|
||||
file: "/managed/codex/defaults.toml",
|
||||
},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
mcp_servers: {
|
||||
"arbitrary.server": { command: "ignored" },
|
||||
|
||||
@@ -77,7 +77,8 @@ export function threadStartResult(threadId = "thread-1"): Record<string, unknown
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp",
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -713,7 +713,8 @@ function threadStartResult(threadId = "thread-1") {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -68,7 +68,7 @@ const CODEX_DELEGATION_DISABLED_THREAD_CONFIG: JsonObject = {
|
||||
"features.multi_agent_v2": false,
|
||||
};
|
||||
|
||||
// Exact Codex 0.147 registry features that can expose a model-visible tool or
|
||||
// Exact Codex 0.149 registry features that can expose a model-visible tool or
|
||||
// host capability. One list owns both the thread deny patch and requirement pin rejection.
|
||||
const CODEX_RING_ZERO_RESTRICTED_FEATURES = new Set([
|
||||
"apps",
|
||||
@@ -139,6 +139,7 @@ const CODEX_RING_ZERO_RESTRICTED_FEATURE_ALIASES = new Map<string, string>([
|
||||
]);
|
||||
|
||||
const CODEX_RING_ZERO_OVERRIDABLE_LAYER_TYPES = new Set([
|
||||
"packagedDefaults",
|
||||
"mdm",
|
||||
"system",
|
||||
"enterpriseManaged",
|
||||
|
||||
@@ -17,6 +17,7 @@ function resumeResponse(threadId: string, restoredTurns = 0) {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/repo",
|
||||
projectId: null,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
|
||||
@@ -4,7 +4,11 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { embeddedAgentLog, type AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
type AgentMessage,
|
||||
type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
@@ -31,6 +35,7 @@ import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js";
|
||||
|
||||
const mirrorCodexAppServerTranscript = codexTranscriptMirrorRuntime.mirror;
|
||||
const mirrorTranscriptBestEffort = codexTranscriptMirrorRuntime.mirrorBestEffort;
|
||||
const deliverAsyncMessageBestEffort = codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort;
|
||||
|
||||
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -983,6 +988,177 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers the persisted async rewrite once across reconnect replay", async () => {
|
||||
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-reconnect-");
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_message_write",
|
||||
handler: () => ({
|
||||
message: castAgentMessage({
|
||||
...makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "[redacted async update]" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
phase: "final_answer",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const message = castAgentMessage({
|
||||
...makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "Sensitive background update." }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
phase: "final_answer",
|
||||
openclawAsyncDelivery: { itemId: "async-update" },
|
||||
});
|
||||
const onBlockReply = vi.fn();
|
||||
const runParams = {
|
||||
agentId: target.agentId,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
sessionTarget: target,
|
||||
workspaceDir: path.dirname(target.storePath),
|
||||
runId: "run-async",
|
||||
onBlockReply,
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
const delivery = {
|
||||
cwd: path.dirname(target.storePath),
|
||||
params: runParams,
|
||||
itemId: "async-update",
|
||||
message,
|
||||
text: "Sensitive background update.",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
|
||||
await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("settled");
|
||||
await expect(
|
||||
deliverAsyncMessageBestEffort({
|
||||
...delivery,
|
||||
params: { ...runParams, runId: "run-async-reconnect" },
|
||||
}),
|
||||
).resolves.toBe("settled");
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||
expect(onBlockReply).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ text: "[redacted async update]" },
|
||||
{
|
||||
deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:async-update",
|
||||
},
|
||||
);
|
||||
expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]);
|
||||
expect(onBlockReply.mock.calls.map(([payload]) => payload)).not.toContainEqual({
|
||||
text: "Sensitive background update.",
|
||||
});
|
||||
expect(await readMirrorMessages(target)).toEqual([
|
||||
{ role: "assistant", text: "[redacted async update]" },
|
||||
]);
|
||||
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]?.update?.message).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "[redacted async update]" }],
|
||||
phase: "final_answer",
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:async:async-update",
|
||||
openclawAsyncDelivery: { itemId: "async-update" },
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a durable async callback from the persisted row", async () => {
|
||||
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-callback-fail-");
|
||||
const onBlockReply = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("channel unavailable"))
|
||||
.mockResolvedValue(undefined);
|
||||
const runParams = {
|
||||
agentId: target.agentId,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
sessionTarget: target,
|
||||
workspaceDir: path.dirname(target.storePath),
|
||||
runId: "run-async-callback-fail",
|
||||
onBlockReply,
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
const delivery = {
|
||||
cwd: path.dirname(target.storePath),
|
||||
params: runParams,
|
||||
itemId: "async-callback-fail",
|
||||
message: castAgentMessage({
|
||||
...makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "Persisted background update." }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
openclawAsyncDelivery: { itemId: "async-callback-fail" },
|
||||
}),
|
||||
text: "Persisted background update.",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
|
||||
await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("retry");
|
||||
await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("settled");
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||
expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]);
|
||||
expect(onBlockReply).toHaveBeenCalledWith(
|
||||
{ text: "Persisted background update." },
|
||||
{
|
||||
deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:async-callback-fail",
|
||||
},
|
||||
);
|
||||
expect(await readMirrorMessages(target)).toEqual([
|
||||
{ role: "assistant", text: "Persisted background update." },
|
||||
]);
|
||||
expect(publishSessionTranscriptUpdateByIdentityMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not deliver async messages blocked by before_message_write", async () => {
|
||||
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-blocked-");
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_message_write", handler: () => ({ block: true }) },
|
||||
]),
|
||||
);
|
||||
const onBlockReply = vi.fn();
|
||||
const runParams = {
|
||||
agentId: target.agentId,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
sessionTarget: target,
|
||||
workspaceDir: path.dirname(target.storePath),
|
||||
runId: "run-async-blocked",
|
||||
onBlockReply,
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
|
||||
await expect(
|
||||
deliverAsyncMessageBestEffort({
|
||||
cwd: path.dirname(target.storePath),
|
||||
params: runParams,
|
||||
itemId: "async-blocked",
|
||||
message: castAgentMessage({
|
||||
...makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "Blocked update." }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
openclawAsyncDelivery: { itemId: "async-blocked" },
|
||||
}),
|
||||
text: "Blocked update.",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
}),
|
||||
).resolves.toBe("settled");
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
expect(await readMirrorMessages(target)).toEqual([]);
|
||||
expect(publishSessionTranscriptUpdateByIdentityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits stable sequence numbers for multi-message mirror batches", async () => {
|
||||
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-seq-");
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
|
||||
import type { CodexAsyncAssistantMessage } from "./event-projector-assistant-message.js";
|
||||
import type { CodexAsyncDeliverySettlement } from "./event-projector-options.js";
|
||||
import type { CodexThread } from "./protocol.js";
|
||||
import {
|
||||
projectBoundedCodexThreadHistory,
|
||||
@@ -40,13 +42,15 @@ import {
|
||||
export { buildCodexUserPromptMessage };
|
||||
export { projectBoundedCodexThreadHistory };
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }> &
|
||||
Partial<Pick<CodexAsyncAssistantMessage, "openclawAsyncDelivery">>;
|
||||
type MirroredUserMessage = Extract<AgentMessage, { role: "user" }>;
|
||||
type MirroredUserMessageReceipt = {
|
||||
anchor: TranscriptEntryAnchor;
|
||||
message: MirroredUserMessage;
|
||||
};
|
||||
type CodexAppServerTranscriptMirrorResult = {
|
||||
assistantMirrorIdentitiesAppended: string[];
|
||||
assistantMirrorIdentitiesOwned: string[];
|
||||
anchorsByMirrorIdentity: Map<string, TranscriptEntryAnchor>;
|
||||
messagesPresent: MirroredAgentMessage[];
|
||||
@@ -58,6 +62,16 @@ function isMirroredAgentMessage(message: AgentMessage): message is MirroredAgent
|
||||
return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
|
||||
}
|
||||
|
||||
function readMirroredAssistantText(message: MirroredAgentMessage | undefined): string | undefined {
|
||||
if (message?.role !== "assistant") {
|
||||
return undefined;
|
||||
}
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
/** Imports a bounded, user-visible Codex history tail into a new OpenClaw transcript. */
|
||||
export async function importCodexThreadHistoryToTranscript(params: {
|
||||
thread: CodexThread;
|
||||
@@ -329,6 +343,7 @@ async function mirror(params: {
|
||||
const messages = params.messages.filter(isMirroredAgentMessage);
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
assistantMirrorIdentitiesAppended: [],
|
||||
assistantMirrorIdentitiesOwned: [],
|
||||
anchorsByMirrorIdentity: new Map(),
|
||||
messagesPresent: [],
|
||||
@@ -363,6 +378,7 @@ async function mirror(params: {
|
||||
message: AgentMessage;
|
||||
messageSeq?: number;
|
||||
}> = [];
|
||||
const nextAssistantMirrorIdentitiesAppended = new Set<string>();
|
||||
const nextAssistantMirrorIdentitiesOwned = new Set<string>();
|
||||
const nextAnchorsByMirrorIdentity = new Map<string, TranscriptEntryAnchor>();
|
||||
const nextMessagesPresent: MirroredAgentMessage[] = [];
|
||||
@@ -429,6 +445,13 @@ async function mirror(params: {
|
||||
// identity so retries cannot turn a stale idempotency hit into evidence.
|
||||
messageToAppend = attachCodexMirrorIdentity(messageToAppend, mirrorIdentity);
|
||||
}
|
||||
if (message.role === "assistant" && message.openclawAsyncDelivery) {
|
||||
// Async delivery ownership is provider-authored. Whole-message hooks may
|
||||
// rewrite content, but must not turn the durable row into a terminal answer.
|
||||
messageToAppend = Object.assign(messageToAppend, {
|
||||
openclawAsyncDelivery: { itemId: message.openclawAsyncDelivery.itemId },
|
||||
});
|
||||
}
|
||||
messageToAppend = projectAgentHarnessTranscriptMessageForDisplay({
|
||||
hidden: (message as { display?: boolean }).display === false,
|
||||
message: messageToAppend,
|
||||
@@ -464,6 +487,9 @@ async function mirror(params: {
|
||||
});
|
||||
}
|
||||
if (appended.appended) {
|
||||
if (message.role === "assistant") {
|
||||
nextAssistantMirrorIdentitiesAppended.add(dedupeIdentity);
|
||||
}
|
||||
nextAppendedUpdates.push({
|
||||
messageId,
|
||||
message: appendedMessage,
|
||||
@@ -479,6 +505,7 @@ async function mirror(params: {
|
||||
}
|
||||
return {
|
||||
appendedUpdates: nextAppendedUpdates,
|
||||
assistantMirrorIdentitiesAppended: [...nextAssistantMirrorIdentitiesAppended],
|
||||
assistantMirrorIdentitiesOwned: [...nextAssistantMirrorIdentitiesOwned],
|
||||
anchorsByMirrorIdentity: nextAnchorsByMirrorIdentity,
|
||||
messagesPresent: nextMessagesPresent,
|
||||
@@ -489,6 +516,7 @@ async function mirror(params: {
|
||||
);
|
||||
const {
|
||||
appendedUpdates,
|
||||
assistantMirrorIdentitiesAppended,
|
||||
assistantMirrorIdentitiesOwned,
|
||||
anchorsByMirrorIdentity,
|
||||
messagesPresent,
|
||||
@@ -518,6 +546,7 @@ async function mirror(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
assistantMirrorIdentitiesAppended,
|
||||
assistantMirrorIdentitiesOwned,
|
||||
anchorsByMirrorIdentity,
|
||||
messagesPresent,
|
||||
@@ -526,7 +555,94 @@ async function mirror(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export const codexTranscriptMirrorRuntime = { mirror, mirrorBestEffort };
|
||||
async function deliverAsyncMessageBestEffort(params: {
|
||||
cwd: string;
|
||||
params: EmbeddedRunAttemptParams;
|
||||
itemId: string;
|
||||
message: AgentMessage;
|
||||
text: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
}): Promise<CodexAsyncDeliverySettlement> {
|
||||
const mirrorIdentity = `${params.turnId}:async:${params.itemId}`;
|
||||
const deliveryIntentId = `block-reply:v1:codex-app-server:${[
|
||||
params.threadId,
|
||||
params.turnId,
|
||||
params.itemId,
|
||||
]
|
||||
.map(encodeURIComponent)
|
||||
.join(":")}`;
|
||||
const target = params.params.sessionTarget;
|
||||
if (!target) {
|
||||
if (!params.params.onBlockReply) {
|
||||
return "retry";
|
||||
}
|
||||
try {
|
||||
await params.params.onBlockReply({ text: params.text }, { deliveryIntentId });
|
||||
return "settled";
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("failed to deliver codex async agent message", {
|
||||
error: formatErrorMessage(error),
|
||||
itemId: params.itemId,
|
||||
runId: params.params.runId,
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
});
|
||||
return "retry";
|
||||
}
|
||||
}
|
||||
|
||||
let result: CodexAppServerTranscriptMirrorResult;
|
||||
try {
|
||||
result = await mirror({
|
||||
agentId: target.agentId ?? params.params.agentId,
|
||||
sessionId: target.sessionId ?? params.params.sessionId,
|
||||
sessionKey: target.sessionKey ?? params.params.sessionKey,
|
||||
storePath: target.storePath,
|
||||
cwd: params.cwd,
|
||||
config: params.params.config,
|
||||
messages: [attachCodexMirrorIdentity(params.message, mirrorIdentity)],
|
||||
idempotencyScope: `codex-app-server:${params.threadId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("failed to persist codex async agent message", {
|
||||
error: formatErrorMessage(error),
|
||||
itemId: params.itemId,
|
||||
runId: params.params.runId,
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
});
|
||||
return "retry";
|
||||
}
|
||||
|
||||
if (!result.assistantMirrorIdentitiesOwned.includes(mirrorIdentity)) {
|
||||
return "retry";
|
||||
}
|
||||
const persistedText = readMirroredAssistantText(
|
||||
result.messagesPresent.find((message) => readMirrorIdentity(message) === mirrorIdentity),
|
||||
);
|
||||
if (params.params.onBlockReply && persistedText !== undefined) {
|
||||
try {
|
||||
await params.params.onBlockReply({ text: persistedText }, { deliveryIntentId });
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("failed to deliver persisted codex async agent message", {
|
||||
error: formatErrorMessage(error),
|
||||
itemId: params.itemId,
|
||||
runId: params.params.runId,
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
});
|
||||
return "retry";
|
||||
}
|
||||
}
|
||||
return "settled";
|
||||
}
|
||||
|
||||
export const codexTranscriptMirrorRuntime = {
|
||||
deliverAsyncMessageBestEffort,
|
||||
mirror,
|
||||
mirrorBestEffort,
|
||||
};
|
||||
|
||||
function resolveCodexMirrorTranscriptTarget(params: {
|
||||
agentId?: string;
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Codex app-server websocket transport", () => {
|
||||
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
|
||||
if (message.method === "initialize") {
|
||||
socket.send(
|
||||
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.147.0" } }),
|
||||
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.149.0" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ function forkResponse(threadId = "thread-forked") {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-forked",
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
createdAt: 1715299200,
|
||||
updatedAt: 1715299200,
|
||||
cwd: "/tmp",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Version and package pins for the managed Codex app-server runtime.
|
||||
*/
|
||||
/** Exact Codex app-server version shipped and supported by the OpenClaw Codex bridge. */
|
||||
export const CODEX_APP_SERVER_VERSION = "0.147.0";
|
||||
/** Exact Codex app-server version shipped by the OpenClaw Codex bridge. */
|
||||
export const CODEX_APP_SERVER_VERSION = "0.149.0";
|
||||
/** Inclusive runtime compatibility floor for external app-server binaries. */
|
||||
export const MIN_SUPPORTED_CODEX_APP_SERVER_VERSION = "0.149.0";
|
||||
/** npm package name for the managed Codex app-server binary. */
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
|
||||
@@ -157,6 +157,7 @@ function createThreadResumeResponse(params: {
|
||||
thread: {
|
||||
id: params.threadId,
|
||||
sessionId: params.threadId,
|
||||
projectId: null,
|
||||
cliVersion: CODEX_APP_SERVER_VERSION,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
|
||||
@@ -368,7 +368,8 @@ function conversationThreadStartResult(threadId: string) {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("codex package manifest", () => {
|
||||
|
||||
expect(packageJson.devDependencies).toHaveProperty("@openclaw/plugin-sdk");
|
||||
expect(packageJson.dependencies?.["@openai/codex"]).toBe(CODEX_APP_SERVER_VERSION);
|
||||
expect(packageJson.dependencies).not.toHaveProperty("semver");
|
||||
expect(packageJson.dependencies?.semver).toBe("7.8.5");
|
||||
expect(packageJson.openclaw?.release?.requireLatestDependencies).toEqual(["@openai/codex"]);
|
||||
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
|
||||
"@openai/codex-linux-x64",
|
||||
|
||||
@@ -440,7 +440,9 @@ describe("Codex supervision actions", () => {
|
||||
|
||||
it("rejects archive when a spawned descendant is active", async () => {
|
||||
const control = createEligibleControl({
|
||||
listDescendantPage: vi.fn(async () => ({ data: [{ id: "active-descendant" }] })),
|
||||
listDescendantPage: vi.fn(async () => ({
|
||||
data: [{ id: "active-descendant", projectId: null }],
|
||||
})),
|
||||
readThread: vi.fn(async (threadId: string) =>
|
||||
idleThread({
|
||||
id: threadId,
|
||||
@@ -470,7 +472,7 @@ describe("Codex supervision actions", () => {
|
||||
const listDescendantPage = vi.fn(async () => {
|
||||
validationReached();
|
||||
await validationReleased;
|
||||
return { data: [{ id: "idle-descendant" }] };
|
||||
return { data: [{ id: "idle-descendant", projectId: null }] };
|
||||
});
|
||||
const control = createEligibleControl({ listDescendantPage });
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ async function readNodeCodexHistory(params: {
|
||||
id: params.record.threadId,
|
||||
createdAt: params.record.createdAt ?? 0,
|
||||
modelProvider: params.record.modelProvider ?? "openai",
|
||||
projectId: null,
|
||||
turns: page.data.toReversed(),
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -275,6 +275,7 @@ export function idleThread(overrides: Partial<CodexThread> = {}): CodexThread {
|
||||
id: "thread-1",
|
||||
name: "Continue native task",
|
||||
cwd: "/workspace/project",
|
||||
projectId: null,
|
||||
status: { type: "idle" },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -48,7 +48,8 @@ function threadStartResult() {
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
cliVersion: "0.147.0",
|
||||
projectId: null,
|
||||
cliVersion: "0.149.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
|
||||
@@ -91,7 +91,7 @@ function classifyOpenAiFailoverCode(code: string | undefined) {
|
||||
const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
|
||||
// Keep synchronized with extensions/codex's exact @openai/codex dependency;
|
||||
// the provider contract test fails when that managed-runtime pin changes.
|
||||
const OPENAI_CODEX_CLIENT_VERSION = "0.147.0";
|
||||
const OPENAI_CODEX_CLIENT_VERSION = "0.149.0";
|
||||
const OPENAI_CODEX_MODELS_ENDPOINT = `${OPENAI_CODEX_RESPONSES_BASE_URL}/models?client_version=${OPENAI_CODEX_CLIENT_VERSION}`;
|
||||
const OPENAI_MODELS_CACHE_TTL_MS = 60_000;
|
||||
const OPENAI_CODEX_MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
Generated
+34
-31
@@ -5,7 +5,7 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@agentclientprotocol/codex-acp@1.1.7>@openai/codex': 0.147.0
|
||||
'@agentclientprotocol/codex-acp@1.1.7>@openai/codex': 0.149.0
|
||||
'@anthropic-ai/sdk': 0.115.0
|
||||
'@opentelemetry/core': 2.10.0
|
||||
'@opentelemetry/propagator-jaeger': 2.10.0
|
||||
@@ -639,8 +639,11 @@ importers:
|
||||
extensions/codex:
|
||||
dependencies:
|
||||
'@openai/codex':
|
||||
specifier: 0.147.0
|
||||
version: 0.147.0
|
||||
specifier: 0.149.0
|
||||
version: 0.149.0
|
||||
semver:
|
||||
specifier: 7.8.5
|
||||
version: 7.8.5
|
||||
smol-toml:
|
||||
specifier: 1.7.1
|
||||
version: 1.7.1
|
||||
@@ -3979,43 +3982,43 @@ packages:
|
||||
resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@openai/codex@0.147.0':
|
||||
resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==}
|
||||
'@openai/codex@0.149.0':
|
||||
resolution: {integrity: sha512-i4dryj2Y1j+00Mb5n+0n71EYnTK9/KDc2cdFo/dXD0d1oTog2bhUssKDEIOnKmnEf51P0Z/HJTWvTKw/UHyOvQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
'@openai/codex@0.147.0-darwin-arm64':
|
||||
resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==}
|
||||
'@openai/codex@0.149.0-darwin-arm64':
|
||||
resolution: {integrity: sha512-GsZJbzBWiD48RETrO8VHGAQNgfSrUVxItXZFeD87wswatPi0+lKuQo8Dx4nMYmOZhZrVtwr3al/feRrZxnDV8Q==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.147.0-darwin-x64':
|
||||
resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==}
|
||||
'@openai/codex@0.149.0-darwin-x64':
|
||||
resolution: {integrity: sha512-H+mMgW3Nhc5QzGWEklCoFqACuOc0cVpgPkPQRw0LShoK7P5664T6BRnyl1yzT6orKPKv49cXry7DIWWZ19SanQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@openai/codex@0.147.0-linux-arm64':
|
||||
resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==}
|
||||
'@openai/codex@0.149.0-linux-arm64':
|
||||
resolution: {integrity: sha512-fAXPpvIob+11RNZJS9CVVTsKb+V4Hw3woGFPj42D7fU2wBJUKI2jfAc4fLJNtrpwRecLeW601mtkMHOSIbWuuA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.147.0-linux-x64':
|
||||
resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==}
|
||||
'@openai/codex@0.149.0-linux-x64':
|
||||
resolution: {integrity: sha512-uZXaN9JPxu0/jjnqqJeTd4kRYPnjVZK3MiVndfG1mHhEaoDKL7ScWHfPqvAEOjwsSDEmQSlMfUkmvYp/CHciYw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@openai/codex@0.147.0-win32-arm64':
|
||||
resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==}
|
||||
'@openai/codex@0.149.0-win32-arm64':
|
||||
resolution: {integrity: sha512-pUd8MzuwtqT5DhM1NUE1gETWIZ9fkDA1XB7tt9YNIi/peUgLuziQgZd7o0bNON4cNzgbil1YUN1qDTgQm0g3pg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@openai/codex@0.147.0-win32-x64':
|
||||
resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==}
|
||||
'@openai/codex@0.149.0-win32-x64':
|
||||
resolution: {integrity: sha512-qKbwSOOO/fdhQ5MlXE2fts6taPxRPZ/zqeC+eqHD72hLRymV9rFCUbUxOCquognUPRPvS/2/kRCV0UVhoDd3yQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -9136,7 +9139,7 @@ snapshots:
|
||||
'@agentclientprotocol/codex-acp@1.1.7':
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 1.3.0(zod@4.4.3)
|
||||
'@openai/codex': 0.147.0
|
||||
'@openai/codex': 0.149.0
|
||||
diff: 9.0.0
|
||||
open: 11.0.0
|
||||
vscode-jsonrpc: 9.0.1
|
||||
@@ -10943,31 +10946,31 @@ snapshots:
|
||||
|
||||
'@npmcli/redact@5.0.0': {}
|
||||
|
||||
'@openai/codex@0.147.0':
|
||||
'@openai/codex@0.149.0':
|
||||
optionalDependencies:
|
||||
'@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64'
|
||||
'@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64'
|
||||
'@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64'
|
||||
'@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64'
|
||||
'@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64'
|
||||
'@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64'
|
||||
'@openai/codex-darwin-arm64': '@openai/codex@0.149.0-darwin-arm64'
|
||||
'@openai/codex-darwin-x64': '@openai/codex@0.149.0-darwin-x64'
|
||||
'@openai/codex-linux-arm64': '@openai/codex@0.149.0-linux-arm64'
|
||||
'@openai/codex-linux-x64': '@openai/codex@0.149.0-linux-x64'
|
||||
'@openai/codex-win32-arm64': '@openai/codex@0.149.0-win32-arm64'
|
||||
'@openai/codex-win32-x64': '@openai/codex@0.149.0-win32-x64'
|
||||
|
||||
'@openai/codex@0.147.0-darwin-arm64':
|
||||
'@openai/codex@0.149.0-darwin-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.147.0-darwin-x64':
|
||||
'@openai/codex@0.149.0-darwin-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.147.0-linux-arm64':
|
||||
'@openai/codex@0.149.0-linux-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.147.0-linux-x64':
|
||||
'@openai/codex@0.149.0-linux-x64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.147.0-win32-arm64':
|
||||
'@openai/codex@0.149.0-win32-arm64':
|
||||
optional: true
|
||||
|
||||
'@openai/codex@0.147.0-win32-x64':
|
||||
'@openai/codex@0.149.0-win32-x64':
|
||||
optional: true
|
||||
|
||||
'@openclaw/crabline@0.1.11':
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ verifyDepsBeforeRun: false
|
||||
blockExoticSubdeps: true
|
||||
|
||||
overrides:
|
||||
"@agentclientprotocol/codex-acp@1.1.7>@openai/codex": 0.147.0
|
||||
"@agentclientprotocol/codex-acp@1.1.7>@openai/codex": 0.149.0
|
||||
"@anthropic-ai/sdk": 0.115.0
|
||||
"@opentelemetry/core": 2.10.0
|
||||
"@opentelemetry/propagator-jaeger": 2.10.0
|
||||
|
||||
@@ -29,6 +29,7 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
{
|
||||
file: "v2/ThreadItem.ts",
|
||||
snippets: [
|
||||
"delivery: AgentMessageDelivery | null",
|
||||
'type: "contextCompaction"',
|
||||
'type: "dynamicToolCall"',
|
||||
'type: "commandExecution"',
|
||||
@@ -78,7 +79,7 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
},
|
||||
{
|
||||
file: "v2/AppsReadParams.ts",
|
||||
snippets: ["appIds: Array<string>", "includeTools?: boolean"],
|
||||
snippets: ["appIds: Array<string>", "threadId?: string | null", "includeTools?: boolean"],
|
||||
},
|
||||
{
|
||||
file: "v2/AppsReadResponse.ts",
|
||||
@@ -129,6 +130,14 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
"overriddenMetadata: OverriddenMetadata | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigLayerSource.ts",
|
||||
snippets: ['type: "packagedDefaults"', "file: AbsolutePathBuf"],
|
||||
},
|
||||
{
|
||||
file: "v2/ConfigReadParams.ts",
|
||||
snippets: ["includeLayers?: boolean", "cwd?: string | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/InstalledApp.ts",
|
||||
snippets: ["runtimeName: string | null", "enabled: boolean", "callable: boolean"],
|
||||
@@ -198,11 +207,44 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
{
|
||||
file: "v2/ThreadStartParams.ts",
|
||||
snippets: [
|
||||
"projectId?: string | null",
|
||||
"permissions?: string | null",
|
||||
"dynamicTools?: Array<DynamicToolSpec> | null",
|
||||
"experimentalRawEvents",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/Thread.ts",
|
||||
snippets: ["projectId: string | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/Model.ts",
|
||||
snippets: ["multiAgentVersion: MultiAgentVersion | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/CodexErrorInfo.ts",
|
||||
snippets: ['"misalignmentPolicyViolation"'],
|
||||
},
|
||||
{
|
||||
file: "v2/McpResourceReadParams.ts",
|
||||
snippets: [
|
||||
"threadId?: string | null",
|
||||
"originCallId?: string | null",
|
||||
"connectorId?: string | null",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/McpResourceReadResponse.ts",
|
||||
snippets: ["originCallId: string | null"],
|
||||
},
|
||||
{
|
||||
file: "v2/StrictReviewRequiredNotification.ts",
|
||||
snippets: ["threadId: string", "turnId: string", "startedAtMs: number"],
|
||||
},
|
||||
{
|
||||
file: "v2/AgentMessageDelivery.ts",
|
||||
snippets: ['"async"'],
|
||||
},
|
||||
{
|
||||
file: "v2/TurnStartParams.ts",
|
||||
snippets: ["permissions?: string | null", "serviceTier?: string | null"],
|
||||
@@ -291,6 +333,7 @@ import type {
|
||||
CodexErrorNotification,
|
||||
CodexGetAccountResponse,
|
||||
CodexModelListResponse,
|
||||
CodexServerNotification,
|
||||
CodexThreadForkParams,
|
||||
CodexThreadForkResponse,
|
||||
CodexThreadResumeParams,
|
||||
@@ -317,8 +360,11 @@ import type { ConfigWriteResponse } from ${JSON.stringify(generatedImport("v2/Co
|
||||
import type { DynamicToolCallParams } from ${JSON.stringify(generatedImport("v2/DynamicToolCallParams.ts"))};
|
||||
import type { DynamicToolSpec } from ${JSON.stringify(generatedImport("v2/DynamicToolSpec.ts"))};
|
||||
import type { ErrorNotification } from ${JSON.stringify(generatedImport("v2/ErrorNotification.ts"))};
|
||||
import type { ConfigReadParams } from ${JSON.stringify(generatedImport("v2/ConfigReadParams.ts"))};
|
||||
import type { GetAccountResponse } from ${JSON.stringify(generatedImport("v2/GetAccountResponse.ts"))};
|
||||
import type { MarketplaceLoadErrorInfo } from ${JSON.stringify(generatedImport("v2/MarketplaceLoadErrorInfo.ts"))};
|
||||
import type { McpResourceReadParams } from ${JSON.stringify(generatedImport("v2/McpResourceReadParams.ts"))};
|
||||
import type { McpResourceReadResponse } from ${JSON.stringify(generatedImport("v2/McpResourceReadResponse.ts"))};
|
||||
import type { ModelListResponse } from ${JSON.stringify(generatedImport("v2/ModelListResponse.ts"))};
|
||||
import type { PluginInstalledParams } from ${JSON.stringify(generatedImport("v2/PluginInstalledParams.ts"))};
|
||||
import type { PluginInstalledResponse } from ${JSON.stringify(generatedImport("v2/PluginInstalledResponse.ts"))};
|
||||
@@ -336,6 +382,7 @@ import type { ThreadResumeParams } from ${JSON.stringify(generatedImport("v2/Thr
|
||||
import type { ThreadResumeResponse } from ${JSON.stringify(generatedImport("v2/ThreadResumeResponse.ts"))};
|
||||
import type { ThreadStartParams } from ${JSON.stringify(generatedImport("v2/ThreadStartParams.ts"))};
|
||||
import type { ThreadStartResponse } from ${JSON.stringify(generatedImport("v2/ThreadStartResponse.ts"))};
|
||||
import type { StrictReviewRequiredNotification } from ${JSON.stringify(generatedImport("v2/StrictReviewRequiredNotification.ts"))};
|
||||
import type { TurnEnvironmentParams } from ${JSON.stringify(generatedImport("v2/TurnEnvironmentParams.ts"))};
|
||||
import type { TurnInterruptParams } from ${JSON.stringify(generatedImport("v2/TurnInterruptParams.ts"))};
|
||||
import type { TurnStartParams } from ${JSON.stringify(generatedImport("v2/TurnStartParams.ts"))};
|
||||
@@ -383,6 +430,10 @@ declare const openClawTurnInterruptParams: CodexAppServerRequestParams<"turn/int
|
||||
const generatedTurnInterruptParams: TurnInterruptParams = openClawTurnInterruptParams;
|
||||
declare const openClawTurnStartParams: CodexTurnStartParams;
|
||||
const generatedTurnStartParams: TurnStartParams = openClawTurnStartParams;
|
||||
declare const openClawMcpResourceReadParams: CodexAppServerRequestParams<"mcpServer/resource/read">;
|
||||
const generatedMcpResourceReadParams: McpResourceReadParams = openClawMcpResourceReadParams;
|
||||
declare const openClawConfigReadParams: CodexAppServerRequestParams<"config/read">;
|
||||
const generatedConfigReadParams: ConfigReadParams = openClawConfigReadParams;
|
||||
|
||||
declare const generatedAppsInstalledResponse: AppsInstalledResponse;
|
||||
const openClawAppsInstalledResponse: CodexAppServerRequestResult<"app/installed"> =
|
||||
@@ -437,6 +488,18 @@ declare const generatedGetAccountResponse: GetAccountResponse;
|
||||
const openClawGetAccountResponse: CodexGetAccountResponse = generatedGetAccountResponse;
|
||||
declare const generatedModelListResponse: ModelListResponse;
|
||||
const openClawModelListResponse: CodexModelListResponse = generatedModelListResponse;
|
||||
declare const generatedMcpResourceReadResponse: McpResourceReadResponse;
|
||||
const openClawMcpResourceReadResponse: CodexAppServerRequestResult<"mcpServer/resource/read"> =
|
||||
generatedMcpResourceReadResponse;
|
||||
declare const generatedStrictReviewRequiredNotification: StrictReviewRequiredNotification;
|
||||
type OpenClawStrictReviewRequiredNotification = Extract<
|
||||
CodexServerNotification,
|
||||
{ method: "autoApprovalReview/strictReviewRequired" }
|
||||
>;
|
||||
const openClawStrictReviewRequiredNotification: OpenClawStrictReviewRequiredNotification = {
|
||||
method: "autoApprovalReview/strictReviewRequired",
|
||||
params: generatedStrictReviewRequiredNotification,
|
||||
};
|
||||
declare const generatedThreadDeleteResponse: ThreadDeleteResponse;
|
||||
const openClawThreadDeleteResponse: CodexAppServerRequestResult<"thread/delete"> =
|
||||
generatedThreadDeleteResponse;
|
||||
@@ -456,21 +519,19 @@ const openClawThreadStartResponse: Omit<CodexThreadStartResponse, "thread"> =
|
||||
export {};
|
||||
`;
|
||||
await fs.writeFile(probePath, probe);
|
||||
const probeConfigPath = path.join(sourceRoot, "openclaw-protocol-compatibility.tsconfig.json");
|
||||
await fs.writeFile(
|
||||
probeConfigPath,
|
||||
JSON.stringify({
|
||||
extends: path.resolve("tsconfig.json"),
|
||||
compilerOptions: { rootDir: process.cwd() },
|
||||
files: [probePath],
|
||||
include: [],
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/run-tsgo.mjs",
|
||||
"--ignoreConfig",
|
||||
"--noEmit",
|
||||
"--allowImportingTsExtensions",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
"--module",
|
||||
"nodenext",
|
||||
"--moduleResolution",
|
||||
"nodenext",
|
||||
probePath,
|
||||
],
|
||||
["scripts/run-tsgo.mjs", "--project", probeConfigPath],
|
||||
{ cwd: process.cwd(), encoding: "utf8" },
|
||||
);
|
||||
if (result.error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
realPathMaybe,
|
||||
stateDir,
|
||||
} from "../codex-install-utils.mjs";
|
||||
import { assertCodexReleasePackageContract } from "../codex-release-package-assertions.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const allowBetaCompatDiagnostics =
|
||||
@@ -447,7 +448,7 @@ function findCodexPackageJson(packageName) {
|
||||
return findPackageJson(packageName, [projectRoot, codexInstallPath(), managedNpmRoot()]);
|
||||
}
|
||||
|
||||
function assertNpmDeps() {
|
||||
function assertNpmDeps(options = {}) {
|
||||
const npmRoot = managedNpmRoot();
|
||||
const installPath = codexInstallPath();
|
||||
const pluginPackageJson = path.join(installPath, "package.json");
|
||||
@@ -468,11 +469,13 @@ function assertNpmDeps() {
|
||||
}
|
||||
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
|
||||
|
||||
const bin = resolveCodexBin();
|
||||
if (!fs.existsSync(bin)) {
|
||||
throw new Error(`missing managed Codex binary: ${bin}`);
|
||||
}
|
||||
assertPathInside(npmRoot, bin, "managed Codex binary");
|
||||
assertCodexReleasePackageContract({
|
||||
pluginPackageJson,
|
||||
codexPackageJson: openAiCodexPackageJson,
|
||||
packageRoots: [codexNpmProjectRoot(), installPath, npmRoot],
|
||||
managedRoot: npmRoot,
|
||||
recordEvidence: options.recordEvidence,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexBin() {
|
||||
@@ -505,7 +508,7 @@ function resolveCodexBin() {
|
||||
}
|
||||
|
||||
function printCodexBin() {
|
||||
assertNpmDeps();
|
||||
assertNpmDeps({ recordEvidence: false });
|
||||
process.stdout.write(`${resolveCodexBin()}\n`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Assertions for Codex on-demand plugin E2E scenarios.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
readJson,
|
||||
stateDir,
|
||||
} from "../codex-install-utils.mjs";
|
||||
import { assertCodexReleasePackageContract } from "../codex-release-package-assertions.mjs";
|
||||
|
||||
const cfg = readJson(configPath());
|
||||
const onboard = readJson("/tmp/openclaw-onboard.json");
|
||||
@@ -62,39 +62,12 @@ if (!openAiCodexPackageJson) {
|
||||
throw new Error("missing @openai/codex dependency under managed npm root");
|
||||
}
|
||||
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
|
||||
const openAiCodexPackage = readJson(openAiCodexPackageJson);
|
||||
const codexBinPath =
|
||||
typeof openAiCodexPackage.bin === "string"
|
||||
? openAiCodexPackage.bin
|
||||
: openAiCodexPackage.bin && typeof openAiCodexPackage.bin.codex === "string"
|
||||
? openAiCodexPackage.bin.codex
|
||||
: undefined;
|
||||
if (!codexBinPath) {
|
||||
throw new Error(`@openai/codex package has no codex bin: ${openAiCodexPackageJson}`);
|
||||
}
|
||||
const codexBin = path.resolve(path.dirname(openAiCodexPackageJson), codexBinPath);
|
||||
if (!fs.existsSync(codexBin)) {
|
||||
throw new Error(`missing managed Codex binary: ${codexBin}`);
|
||||
}
|
||||
assertPathInside(npmRoot, codexBin, "managed Codex binary");
|
||||
const codexVersion = spawnSync(process.execPath, [codexBin, "--version"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024,
|
||||
timeout: 15_000,
|
||||
windowsHide: true,
|
||||
assertCodexReleasePackageContract({
|
||||
pluginPackageJson: codexPackageJson,
|
||||
codexPackageJson: openAiCodexPackageJson,
|
||||
packageRoots: [installPath, npmProjectRoot, npmRoot],
|
||||
managedRoot: npmRoot,
|
||||
});
|
||||
const codexVersionStdout = codexVersion.stdout?.trim() ?? "";
|
||||
const codexVersionStderr = codexVersion.stderr?.trim() ?? "";
|
||||
if (codexVersion.error || codexVersion.status !== 0) {
|
||||
const failure = codexVersion.error?.message ?? `exit status ${String(codexVersion.status)}`;
|
||||
const output = codexVersionStderr || codexVersionStdout || "no output";
|
||||
throw new Error(`managed Codex --version failed (${failure}): ${output}`);
|
||||
}
|
||||
if (!/^codex-cli\s+\S+$/u.test(codexVersionStdout)) {
|
||||
throw new Error(
|
||||
`unexpected managed Codex --version output: ${JSON.stringify(codexVersionStdout)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const list = readJson("/tmp/openclaw-plugins-list.json");
|
||||
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type CodexReleasePackageEvidence = {
|
||||
packageVersion: string;
|
||||
cliVersion: string;
|
||||
platformAlias: string;
|
||||
platformVersion: string;
|
||||
platformOs: string;
|
||||
platformCpu: string;
|
||||
};
|
||||
|
||||
export function assertCodexReleasePackageContract(params: {
|
||||
pluginPackageJson: string;
|
||||
codexPackageJson: string;
|
||||
packageRoots: string[];
|
||||
managedRoot: string;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: NodeJS.Architecture;
|
||||
recordEvidence?: boolean;
|
||||
}): {
|
||||
codexBin: string;
|
||||
evidence: CodexReleasePackageEvidence;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
// Exact package assertions shared by Codex release install scenarios.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { assertPathInside, findPackageJson, readJson } from "./codex-install-utils.mjs";
|
||||
|
||||
const EXPECTED_CODEX_VERSION = "0.149.0";
|
||||
const CODEX_PLATFORM_TARGETS = new Map([
|
||||
["linux:x64", { alias: "@openai/codex-linux-x64", os: "linux", cpu: "x64" }],
|
||||
["linux:arm64", { alias: "@openai/codex-linux-arm64", os: "linux", cpu: "arm64" }],
|
||||
["darwin:x64", { alias: "@openai/codex-darwin-x64", os: "darwin", cpu: "x64" }],
|
||||
["darwin:arm64", { alias: "@openai/codex-darwin-arm64", os: "darwin", cpu: "arm64" }],
|
||||
["win32:x64", { alias: "@openai/codex-win32-x64", os: "win32", cpu: "x64" }],
|
||||
["win32:arm64", { alias: "@openai/codex-win32-arm64", os: "win32", cpu: "arm64" }],
|
||||
]);
|
||||
|
||||
function exactStringArray(value, expected) {
|
||||
return Array.isArray(value) && value.length === 1 && value[0] === expected;
|
||||
}
|
||||
|
||||
function recordEvidence(evidence) {
|
||||
for (const [key, value] of Object.entries(evidence)) {
|
||||
process.stdout.write(`[codex-release] ${key}=${value}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCodexReleasePackageContract(params) {
|
||||
const platform = params.platform ?? process.platform;
|
||||
const arch = params.arch ?? process.arch;
|
||||
const target = CODEX_PLATFORM_TARGETS.get(`${platform}:${arch}`);
|
||||
if (!target) {
|
||||
throw new Error(`unsupported Codex release platform: ${platform}/${arch}`);
|
||||
}
|
||||
|
||||
const pluginPackage = readJson(params.pluginPackageJson);
|
||||
const expectedDependency = pluginPackage.dependencies?.["@openai/codex"];
|
||||
if (expectedDependency !== EXPECTED_CODEX_VERSION) {
|
||||
throw new Error(
|
||||
`@openclaw/codex must depend on @openai/codex ${EXPECTED_CODEX_VERSION}; found ${String(expectedDependency)}`,
|
||||
);
|
||||
}
|
||||
const requiredPlatformPackages = pluginPackage.openclaw?.install?.requiredPlatformPackages;
|
||||
if (
|
||||
!Array.isArray(requiredPlatformPackages) ||
|
||||
!requiredPlatformPackages.includes(target.alias)
|
||||
) {
|
||||
throw new Error(
|
||||
`@openclaw/codex manifest does not require current platform alias ${target.alias}`,
|
||||
);
|
||||
}
|
||||
|
||||
assertPathInside(params.managedRoot, params.codexPackageJson, "@openai/codex dependency");
|
||||
const codexPackage = readJson(params.codexPackageJson);
|
||||
if (codexPackage.version !== EXPECTED_CODEX_VERSION) {
|
||||
throw new Error(
|
||||
`installed @openai/codex version mismatch: expected ${EXPECTED_CODEX_VERSION}, got ${String(codexPackage.version)}`,
|
||||
);
|
||||
}
|
||||
const expectedAliasSpec = `npm:@openai/codex@${EXPECTED_CODEX_VERSION}-${platform}-${arch}`;
|
||||
if (codexPackage.optionalDependencies?.[target.alias] !== expectedAliasSpec) {
|
||||
throw new Error(
|
||||
`@openai/codex current platform alias mismatch: expected ${target.alias}=${expectedAliasSpec}`,
|
||||
);
|
||||
}
|
||||
|
||||
const platformPackageJson = findPackageJson(target.alias, params.packageRoots);
|
||||
if (!platformPackageJson) {
|
||||
throw new Error(`missing current Codex platform alias ${target.alias}`);
|
||||
}
|
||||
assertPathInside(params.managedRoot, platformPackageJson, "Codex platform package");
|
||||
const platformPackage = readJson(platformPackageJson);
|
||||
const expectedPlatformVersion = `${EXPECTED_CODEX_VERSION}-${platform}-${arch}`;
|
||||
if (platformPackage.version !== expectedPlatformVersion) {
|
||||
throw new Error(
|
||||
`installed ${target.alias} version mismatch: expected ${expectedPlatformVersion}, got ${String(platformPackage.version)}`,
|
||||
);
|
||||
}
|
||||
if (!exactStringArray(platformPackage.os, target.os)) {
|
||||
throw new Error(
|
||||
`installed ${target.alias} os mismatch: expected [${target.os}], got ${JSON.stringify(platformPackage.os)}`,
|
||||
);
|
||||
}
|
||||
if (!exactStringArray(platformPackage.cpu, target.cpu)) {
|
||||
throw new Error(
|
||||
`installed ${target.alias} cpu mismatch: expected [${target.cpu}], got ${JSON.stringify(platformPackage.cpu)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const codexBinPath =
|
||||
typeof codexPackage.bin === "string"
|
||||
? codexPackage.bin
|
||||
: codexPackage.bin && typeof codexPackage.bin.codex === "string"
|
||||
? codexPackage.bin.codex
|
||||
: undefined;
|
||||
if (!codexBinPath) {
|
||||
throw new Error(`@openai/codex package has no codex bin: ${params.codexPackageJson}`);
|
||||
}
|
||||
const codexBin = path.resolve(path.dirname(params.codexPackageJson), codexBinPath);
|
||||
if (!fs.existsSync(codexBin)) {
|
||||
throw new Error(`missing managed Codex binary: ${codexBin}`);
|
||||
}
|
||||
assertPathInside(params.managedRoot, codexBin, "managed Codex binary");
|
||||
const versionRun = spawnSync(process.execPath, [codexBin, "--version"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024,
|
||||
timeout: 15_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdout = versionRun.stdout?.trim() ?? "";
|
||||
const stderr = versionRun.stderr?.trim() ?? "";
|
||||
if (versionRun.error || versionRun.status !== 0) {
|
||||
const failure = versionRun.error?.message ?? `exit status ${String(versionRun.status)}`;
|
||||
throw new Error(
|
||||
`managed Codex --version failed (${failure}): ${stderr || stdout || "no output"}`,
|
||||
);
|
||||
}
|
||||
const versionMatch = /^codex-cli\s+(\S+)$/u.exec(stdout);
|
||||
if (versionMatch?.[1] !== EXPECTED_CODEX_VERSION) {
|
||||
throw new Error(
|
||||
`managed Codex CLI version mismatch: expected ${EXPECTED_CODEX_VERSION}, got ${JSON.stringify(stdout)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
packageVersion: codexPackage.version,
|
||||
cliVersion: versionMatch[1],
|
||||
platformAlias: target.alias,
|
||||
platformVersion: platformPackage.version,
|
||||
platformOs: target.os,
|
||||
platformCpu: target.cpu,
|
||||
};
|
||||
if (params.recordEvidence !== false) {
|
||||
recordEvidence(evidence);
|
||||
}
|
||||
return { codexBin, evidence };
|
||||
}
|
||||
@@ -284,6 +284,27 @@ export async function validateCodexProtocolSourceVersion(params: {
|
||||
`Codex protocol source version ${sourceVersion ?? "<unknown>"} does not match @openai/codex ${expectedVersion}. Check out rust-v${expectedVersion} in ${params.codexRepo}.`,
|
||||
);
|
||||
}
|
||||
const expectedTag = `rust-v${expectedVersion}`;
|
||||
const headCommit = readGitCommit(params.codexRepo, "HEAD");
|
||||
const tagCommit = readGitCommit(params.codexRepo, `refs/tags/${expectedTag}`);
|
||||
if (headCommit !== tagCommit) {
|
||||
throw new Error(
|
||||
`Codex protocol source HEAD ${headCommit} does not match peeled ${expectedTag} commit ${tagCommit}. Check out the exact tag in ${params.codexRepo}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readGitCommit(cwd: string, ref: string): string {
|
||||
const result = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const commit = result.stdout.trim();
|
||||
if (result.status !== 0 || !commit) {
|
||||
const detail = result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
||||
throw new Error(`Could not resolve Codex protocol source ${ref}: ${detail}`);
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
async function collectCodexRepoCandidates(repoRoot: string): Promise<string[]> {
|
||||
|
||||
@@ -195,6 +195,14 @@ export function isIntermediateAssistantTranscriptMessage(message: unknown): bool
|
||||
if (record.stopReason !== undefined && record.stopReason !== "stop") {
|
||||
return false;
|
||||
}
|
||||
const asyncDelivery = record.openclawAsyncDelivery;
|
||||
if (asyncDelivery && typeof asyncDelivery === "object" && !Array.isArray(asyncDelivery)) {
|
||||
// SAFETY: the object/non-array guard permits reading an optional itemId as unknown.
|
||||
const itemId = (asyncDelivery as { itemId?: unknown }).itemId;
|
||||
if (typeof itemId === "string" && itemId.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const phase = resolveAssistantMessagePhase(message);
|
||||
if (phase !== undefined) {
|
||||
return phase === "commentary";
|
||||
|
||||
+34
-2
@@ -28,6 +28,16 @@ function progressMessage(text: string, itemId: string): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function asyncDeliveryMessage(text: string, itemId: string): Record<string, unknown> {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
stopReason: "stop",
|
||||
phase: "final_answer",
|
||||
openclawAsyncDelivery: { itemId },
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePolicy(params: {
|
||||
messages?: unknown[];
|
||||
beforeAgentReplyState?:
|
||||
@@ -244,7 +254,16 @@ describe("resolveMainSessionResumePolicy progress tails", () => {
|
||||
).toEqual({ action: "resume", forceRestartSafeTools: false });
|
||||
});
|
||||
|
||||
it("retains replay restrictions when progress follows a side-effecting tool call", () => {
|
||||
it("keeps durable async delivery visible without treating it as the terminal answer", () => {
|
||||
expect(
|
||||
resolveMainSessionResumePolicy([
|
||||
{ role: "user", content: "finish the interrupted work" },
|
||||
asyncDeliveryMessage("A background agent completed.", "async-agent-1"),
|
||||
]),
|
||||
).toEqual({ action: "resume", forceRestartSafeTools: false });
|
||||
});
|
||||
|
||||
it("retains replay restrictions when final-phase async delivery follows a side-effecting call", () => {
|
||||
expect(
|
||||
resolveMainSessionResumePolicy([
|
||||
{ role: "user", content: "finish the interrupted work" },
|
||||
@@ -255,7 +274,7 @@ describe("resolveMainSessionResumePolicy progress tails", () => {
|
||||
{ type: "toolCall", id: "call-bash", name: "bash", arguments: { command: "true" } },
|
||||
],
|
||||
},
|
||||
progressMessage("Waiting for the command.", "progress-exec"),
|
||||
asyncDeliveryMessage("The background check finished.", "async-after-exec"),
|
||||
]),
|
||||
).toEqual({ action: "resume", forceRestartSafeTools: true });
|
||||
});
|
||||
@@ -284,5 +303,18 @@ describe("resolveMainSessionResumePolicy progress tails", () => {
|
||||
},
|
||||
]),
|
||||
).toEqual({ action: "resume", forceRestartSafeTools: false });
|
||||
|
||||
expect(
|
||||
resolveMainSessionResumePolicy([
|
||||
{ role: "user", content: "finish the interrupted work" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "The work is complete." }],
|
||||
stopReason: "stop",
|
||||
phase: "final_answer",
|
||||
openclawAsyncDelivery: { itemId: " " },
|
||||
},
|
||||
]),
|
||||
).toEqual({ action: "resume", forceRestartSafeTools: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ export type BlockReplyContext = {
|
||||
timeoutMs?: number;
|
||||
/** Source assistant message index from the upstream stream, when available. */
|
||||
assistantMessageIndex?: number;
|
||||
/** @internal Stable durable outbound intent owned by the producing runtime. */
|
||||
deliveryIntentId?: string;
|
||||
};
|
||||
|
||||
/** Context passed to onModelSelected callback with actual model used. */
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
import { buildTerminalAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js";
|
||||
import { takeCommandSessionMetadataChanges } from "./command-session-metadata.js";
|
||||
import { runWithDispatchAbortSignal } from "./dispatch-from-config.abort.js";
|
||||
import {
|
||||
type InternalReplyResolverOptions,
|
||||
createReplyDispatchEvent,
|
||||
} from "./dispatch-from-config.events.js";
|
||||
import { createReplyDispatchEvent } from "./dispatch-from-config.events.js";
|
||||
import type { InternalReplyResolverOptions } from "./dispatch-from-config.events.js";
|
||||
import {
|
||||
hasAskUserPayload,
|
||||
prepareReplyPayloadForSideEffects as preparePayload,
|
||||
@@ -74,8 +72,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
waitForPendingDirectBlockReplyDelivery,
|
||||
wrapProgressCallback,
|
||||
} = state;
|
||||
// Bind at the invocation boundary so every public three-argument resolver consumes the same
|
||||
// request-scoped generation without widening its Plugin SDK contract.
|
||||
// Bind at invocation so every public resolver consumes the request generation without widening its Plugin SDK contract.
|
||||
const replyResolver = bindPreparedReplyDispatchRuntime(
|
||||
params.configOverride ? undefined : state.preparedReplyDispatchRuntime,
|
||||
state.replyResolver,
|
||||
@@ -209,8 +206,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
markInboundDedupeReplayUnsafe();
|
||||
// Buffered commentary preceded this tool; land it before the summary.
|
||||
await flushPendingCommentaryProgress();
|
||||
// Tool-error suppression covers visible progress as well as warning text,
|
||||
// regardless of source delivery mode.
|
||||
// Tool-error suppression covers visible progress and warnings regardless of source delivery mode.
|
||||
if (
|
||||
payload.isError === true &&
|
||||
replyConfig.messages?.suppressToolErrors === true
|
||||
@@ -551,12 +547,13 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (shouldRouteToOriginating) {
|
||||
if (context?.deliveryIntentId || shouldRouteToOriginating) {
|
||||
const result = await sendPayloadAsync(
|
||||
normalizedPayload,
|
||||
context?.abortSignal,
|
||||
false,
|
||||
"block",
|
||||
context?.deliveryIntentId,
|
||||
);
|
||||
state.recordRoutedBlockReplyDelivery(normalizedPayload, result);
|
||||
if (result?.delivered === true && !state.suppressAutomaticSourceDelivery) {
|
||||
|
||||
@@ -85,17 +85,9 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
});
|
||||
const routeReplyTo = replyRoute.to;
|
||||
const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface;
|
||||
const shouldPrepareRoutedReplyDelivery = shouldRouteToOriginating && Boolean(routeReplyChannel);
|
||||
const replyContextAccountId = routeReplyChannel
|
||||
? resolveReplyDeliveryAccountId(cfg, routeReplyChannel, replyRoute.accountId)
|
||||
: undefined;
|
||||
const routedReplyAccountId = shouldPrepareRoutedReplyDelivery ? replyContextAccountId : undefined;
|
||||
const routedReplyDelivery = shouldPrepareRoutedReplyDelivery
|
||||
? createReplyDeliveryContext(
|
||||
resolveReplyToMode(cfg, routeReplyChannel, routedReplyAccountId, replyRoute.chatType),
|
||||
replyRoute.chatType,
|
||||
)
|
||||
: undefined;
|
||||
let normalizeReplyMediaPaths:
|
||||
| ReturnType<
|
||||
(typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"]
|
||||
@@ -138,9 +130,20 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
kind?: ReplyDispatchKind;
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
sessionKey?: string;
|
||||
deliveryIntentId?: string;
|
||||
},
|
||||
) => {
|
||||
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
|
||||
const runtime =
|
||||
routeReplyRuntime ?? (options?.deliveryIntentId ? await loadRouteReplyRuntime() : undefined);
|
||||
if (
|
||||
(!shouldRouteToOriginating && !options?.deliveryIntentId) ||
|
||||
!routeReplyChannel ||
|
||||
!routeReplyTo ||
|
||||
!runtime
|
||||
) {
|
||||
if (options?.deliveryIntentId) {
|
||||
throw new Error("durable block reply route unavailable");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
@@ -152,7 +155,7 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
(ctx.CommandSource === "native"
|
||||
? (resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey)
|
||||
: ctx.SessionKey);
|
||||
const result = await routeReplyRuntime.routeReply({
|
||||
const result = await runtime.routeReply({
|
||||
payload,
|
||||
channel: routeReplyChannel,
|
||||
to: routeReplyTo,
|
||||
@@ -160,13 +163,16 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
policySessionKey:
|
||||
options?.sessionKey ?? resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey,
|
||||
policyConversationType: resolveRoutedPolicyConversationType(ctx),
|
||||
accountId: routedReplyAccountId,
|
||||
accountId: replyContextAccountId,
|
||||
requesterSenderId: ctx.SenderId,
|
||||
requesterSenderName: ctx.SenderName,
|
||||
requesterSenderUsername: ctx.SenderUsername,
|
||||
requesterSenderE164: ctx.SenderE164,
|
||||
threadId: state.routeReplyThreadId,
|
||||
replyDelivery: routedReplyDelivery,
|
||||
replyDelivery: createReplyDeliveryContext(
|
||||
resolveReplyToMode(cfg, routeReplyChannel, replyContextAccountId, replyRoute.chatType),
|
||||
replyRoute.chatType,
|
||||
),
|
||||
cfg,
|
||||
abortSignal: options?.abortSignal,
|
||||
mirror: options?.mirror,
|
||||
@@ -175,6 +181,7 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
replyKind: options?.kind ?? "final",
|
||||
runId: state.params.replyOptions?.runId,
|
||||
responsePrefixContext: options?.responsePrefixContext,
|
||||
deliveryIntentId: options?.deliveryIntentId,
|
||||
});
|
||||
// Routed sends settle here: the transport result is the settlement. This is
|
||||
// the single routed choke point, so every routed lane feeds the turn ledger.
|
||||
@@ -195,10 +202,11 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
abortSignal?: AbortSignal,
|
||||
mirror?: boolean,
|
||||
kind: ReplyDispatchKind = "tool",
|
||||
deliveryIntentId?: string,
|
||||
) => {
|
||||
// Keep the runtime guard explicit because this helper is called from nested
|
||||
// reply callbacks where TypeScript cannot narrow shouldRouteToOriginating.
|
||||
if (!routeReplyRuntime || !routeReplyChannel || !routeReplyTo) {
|
||||
if (!routeReplyRuntime && !deliveryIntentId) {
|
||||
return null;
|
||||
}
|
||||
const effectiveAbortSignal = abortSignal ?? state.getDispatchAbortSignal();
|
||||
@@ -209,9 +217,13 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS
|
||||
abortSignal: effectiveAbortSignal,
|
||||
mirror,
|
||||
kind,
|
||||
deliveryIntentId,
|
||||
});
|
||||
if (result && !result.ok) {
|
||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||
if (deliveryIntentId) {
|
||||
throw new Error(result.error ?? "durable block reply delivery failed");
|
||||
}
|
||||
}
|
||||
if (hasAskUserPayload(payload) && !effectiveAbortSignal?.aborted && !result?.delivered) {
|
||||
throw new Error("ask_user prompt delivery failed");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ttsMocks,
|
||||
} from "./dispatch-from-config.shared.test-harness.js";
|
||||
import {
|
||||
automaticDirectReplyConfig,
|
||||
automaticGroupReplyConfig,
|
||||
dispatchReplyFromConfig,
|
||||
setNoAbort,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
firstToolResultPayload,
|
||||
firstRouteReplyCall,
|
||||
installThreadingTestPlugin,
|
||||
requireBlockReplyHandler,
|
||||
requireToolResultHandler,
|
||||
globalBeforeAll0,
|
||||
describe0BeforeEach0,
|
||||
@@ -331,6 +333,221 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(routeCall?.to).toBe("imessage:+15550001111");
|
||||
});
|
||||
|
||||
it("passes a stable block delivery intent to routed durable delivery", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const dispatcher = createDispatcher();
|
||||
const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-1";
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
});
|
||||
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await requireBlockReplyHandler(opts?.onBlockReply)(
|
||||
{ text: "durable background update" },
|
||||
{ deliveryIntentId },
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: { text: "durable background update" },
|
||||
replyKind: "block",
|
||||
deliveryIntentId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps same-channel stable block delivery on the resolved source account", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({
|
||||
id: "telegram",
|
||||
defaultAccountId: "default",
|
||||
resolveReplyToMode: ({ accountId }) => (accountId === "work" ? "off" : "all"),
|
||||
});
|
||||
sessionStoreMocks.currentEntry = { ttsAuto: "always" };
|
||||
const dispatcher = createDispatcher();
|
||||
const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-same";
|
||||
let releaseCustody!: () => void;
|
||||
let markCustodyStarted!: () => void;
|
||||
const custodyStarted = new Promise<void>((resolve) => {
|
||||
markCustodyStarted = resolve;
|
||||
});
|
||||
const custodyGate = new Promise<void>((resolve) => {
|
||||
releaseCustody = resolve;
|
||||
});
|
||||
mocks.routeReply.mockImplementationOnce(async () => {
|
||||
markCustodyStarted();
|
||||
await custodyGate;
|
||||
return { ok: true, delivered: true, messageId: "durable" };
|
||||
});
|
||||
ttsMocks.maybeApplyTtsToPayload.mockResolvedValueOnce({
|
||||
text: "durable background update",
|
||||
mediaUrl: "https://example.com/block-tts.opus",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
const onBlockReplyQueued = vi.fn();
|
||||
let blockSettled = false;
|
||||
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
const block = Promise.resolve(
|
||||
requireBlockReplyHandler(opts?.onBlockReply)(
|
||||
{ text: "durable background update" },
|
||||
{ deliveryIntentId },
|
||||
),
|
||||
).then(() => {
|
||||
blockSettled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(mocks.routeReply).toHaveBeenCalledOnce());
|
||||
await custodyStarted;
|
||||
expect(blockSettled).toBe(false);
|
||||
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
||||
releaseCustody();
|
||||
await block;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
AccountId: "work",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
}),
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyOptions: { onBlockReplyQueued },
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
text: "durable background update",
|
||||
mediaUrl: "https://example.com/block-tts.opus",
|
||||
audioAsVoice: true,
|
||||
}),
|
||||
accountId: "work",
|
||||
replyDelivery: { chatType: "direct", replyToMode: "off" },
|
||||
replyKind: "block",
|
||||
deliveryIntentId,
|
||||
}),
|
||||
);
|
||||
expect(onBlockReplyQueued).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps same-channel blocks without a stable intent on the dispatcher", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
const dispatcher = createDispatcher();
|
||||
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await requireBlockReplyHandler(opts?.onBlockReply)({ text: "ordinary block" });
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
}),
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
expect(mocks.routeReply).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "ordinary block" });
|
||||
});
|
||||
|
||||
it("rejects failed same-channel stable admission and retries the same intent", async () => {
|
||||
setNoAbort();
|
||||
const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-retry";
|
||||
mocks.routeReply
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
delivered: false,
|
||||
error: "durable queue unavailable",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, delivered: true, messageId: "retried" });
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
});
|
||||
const dispatch = () =>
|
||||
dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await requireBlockReplyHandler(opts?.onBlockReply)(
|
||||
{ text: "retry this update" },
|
||||
{ deliveryIntentId },
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(dispatch()).rejects.toThrow("durable queue unavailable");
|
||||
await expect(dispatch()).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.routeReply).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.routeReply.mock.calls.map(([call]) => call)).toEqual([
|
||||
expect.objectContaining({ deliveryIntentId }),
|
||||
expect.objectContaining({ deliveryIntentId }),
|
||||
]);
|
||||
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns durable routed block failures to the producing runtime", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockReset().mockResolvedValue({
|
||||
ok: false,
|
||||
delivered: false,
|
||||
error: "durable queue unavailable",
|
||||
});
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
});
|
||||
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await requireBlockReplyHandler(opts?.onBlockReply)(
|
||||
{ text: "retry this update" },
|
||||
{ deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-2" },
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await expect(
|
||||
dispatchReplyFromConfig({
|
||||
ctx,
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyResolver,
|
||||
}),
|
||||
).rejects.toThrow("durable queue unavailable");
|
||||
});
|
||||
|
||||
it("routes media-only tool results when summaries are suppressed", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Tests dispatch-from-config runtime selection, hooks, and provider handoff.
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { clearAgentHarnesses } from "../../agents/harness/registry.js";
|
||||
import type { ChannelMessagingAdapter } from "../../channels/plugins/types.core.js";
|
||||
import type {
|
||||
ChannelMessagingAdapter,
|
||||
ChannelThreadingAdapter,
|
||||
} from "../../channels/plugins/types.core.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
@@ -286,7 +289,11 @@ export function firstRouteReplyCall(): Record<string, unknown> {
|
||||
return call as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function installThreadingTestPlugin(params: { defaultAccountId?: string; id: string }) {
|
||||
export function installThreadingTestPlugin(params: {
|
||||
defaultAccountId?: string;
|
||||
id: string;
|
||||
resolveReplyToMode?: NonNullable<ChannelThreadingAdapter["resolveReplyToMode"]>;
|
||||
}) {
|
||||
const plugin = createChannelTestPluginBase({ id: params.id });
|
||||
const defaultAccountId = params.defaultAccountId;
|
||||
const registry = createTestRegistry([
|
||||
@@ -299,7 +306,7 @@ export function installThreadingTestPlugin(params: { defaultAccountId?: string;
|
||||
? { ...plugin.config, defaultAccountId: () => defaultAccountId }
|
||||
: plugin.config,
|
||||
threading: {
|
||||
resolveReplyToMode: () => "all",
|
||||
resolveReplyToMode: params.resolveReplyToMode ?? (() => "all"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -316,6 +316,7 @@ describe("routeReply", () => {
|
||||
requesterSenderId: "sender-1",
|
||||
replyKind: "block",
|
||||
runId: "run-1",
|
||||
deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-1",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
@@ -335,6 +336,16 @@ describe("routeReply", () => {
|
||||
},
|
||||
});
|
||||
expect(lastDelivery()).not.toHaveProperty("skipMessageSendingHooks");
|
||||
expectLastDeliveryFields({
|
||||
queuePolicy: "required",
|
||||
deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-1",
|
||||
reusePendingDeliveryIntent: true,
|
||||
completionRetention: {
|
||||
idPrefix: "block-reply:v1:",
|
||||
maxAgeMs: 24 * 60 * 60_000,
|
||||
maxEntries: 2_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses payload reply policy when resolving the final Slack transport", async () => {
|
||||
|
||||
@@ -38,6 +38,12 @@ const messageRuntimeLoader = createLazyImportLoader(
|
||||
() => import("../../channels/message/runtime.js"),
|
||||
);
|
||||
|
||||
const BLOCK_REPLY_COMPLETION_RETENTION = {
|
||||
idPrefix: "block-reply:v1:",
|
||||
maxAgeMs: 24 * 60 * 60_000,
|
||||
maxEntries: 2_000,
|
||||
} as const;
|
||||
|
||||
function loadDeliverRuntime() {
|
||||
return messageRuntimeLoader.load();
|
||||
}
|
||||
@@ -105,6 +111,8 @@ type RouteReplyParams = {
|
||||
replyKind: ReplyDispatchKind;
|
||||
/** Agent run id for hook context. */
|
||||
runId?: string;
|
||||
/** @internal Stable producer-owned block delivery intent. */
|
||||
deliveryIntentId?: string;
|
||||
/** Model/session context for response-prefix template interpolation. */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
};
|
||||
@@ -354,6 +362,14 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
threadId: resolvedThreadId,
|
||||
session: outboundSession,
|
||||
signal: abortSignal,
|
||||
...(params.deliveryIntentId
|
||||
? {
|
||||
deliveryIntentId: params.deliveryIntentId,
|
||||
reusePendingDeliveryIntent: true,
|
||||
completionRetention: BLOCK_REPLY_COMPLETION_RETENTION,
|
||||
durability: "required" as const,
|
||||
}
|
||||
: {}),
|
||||
mirror:
|
||||
params.mirror !== false && params.sessionKey
|
||||
? {
|
||||
|
||||
@@ -2007,6 +2007,42 @@ describe("config plugin validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("admits the beta.2 Codex untrusted policy for doctor migration", () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
{
|
||||
agents: { list: [{ id: "openclaw" }] },
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
approvalPolicy: "untrusted",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...suiteEnv(),
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.plugins?.entries?.codex?.config).toMatchObject({
|
||||
appServer: { approvalPolicy: "untrusted" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts ask destructive policy without dropping adjacent Codex plugin config", () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
{
|
||||
|
||||
@@ -40,6 +40,16 @@ import { acceptedPreparedOutboundEntries } from "./prepared-batch.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/deliver");
|
||||
|
||||
function isReusablePreparedDeliveryOwner(
|
||||
owner: ReturnType<typeof findDeliveryIntentOwner>,
|
||||
): boolean {
|
||||
// Pending recovery or a retained completion receipt already owns the effect.
|
||||
// A replaying producer accepts that custody instead of creating another send.
|
||||
return (
|
||||
owner?.namespace === "prepared" && (owner.status === "pending" || owner.status === "completed")
|
||||
);
|
||||
}
|
||||
|
||||
export async function runOutboundDelivery(
|
||||
params: DeliverOutboundPayloadsParams,
|
||||
): Promise<OutboundDeliveryResult[]> {
|
||||
@@ -69,6 +79,12 @@ export async function runOutboundDeliveryInternal(
|
||||
if (claim.status === "claimed") {
|
||||
return claim.value;
|
||||
}
|
||||
const owner = params.reusePendingDeliveryIntent
|
||||
? findDeliveryIntentOwner(stableIntentId)
|
||||
: null;
|
||||
if (isReusablePreparedDeliveryOwner(owner)) {
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Stable delivery intent is already queued: ${stableIntentId}`);
|
||||
}
|
||||
return await runOutboundDeliveryWithQueue(params, false);
|
||||
@@ -166,6 +182,9 @@ async function runOutboundDeliveryWithQueue(
|
||||
if (params.deliveryIntentId && !existingStableDelivery && !stablePreparationOwner) {
|
||||
const owner = findDeliveryIntentOwner(params.deliveryIntentId);
|
||||
if (owner) {
|
||||
if (params.reusePendingDeliveryIntent && isReusablePreparedDeliveryOwner(owner)) {
|
||||
return [];
|
||||
}
|
||||
throw new Error(
|
||||
owner.namespace === "legacy"
|
||||
? `Stable delivery intent is awaiting queue migration: ${params.deliveryIntentId}`
|
||||
@@ -379,7 +398,7 @@ async function runOutboundDeliveryWithQueue(
|
||||
return claimResult.value;
|
||||
}
|
||||
if (params.reusePendingDeliveryIntent) {
|
||||
throw new Error(`Stable delivery intent is already queued: ${queueId}`);
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { getDeliveryQueueEntryStatus } from "../delivery-queue-sqlite.js";
|
||||
import {
|
||||
drainMatrixReconnect,
|
||||
matrixOutboundForQueueTest,
|
||||
} from "./deliver.queue-integration.test-support.js";
|
||||
import { OUTBOUND_DELIVERY_QUEUE_NAME } from "./delivery-queue-media-staging.js";
|
||||
import { recoverPendingDeliveries, type DeliverFn } from "./delivery-queue-recovery.js";
|
||||
import { enqueueDeliveryOnce } from "./delivery-queue-storage.js";
|
||||
import {
|
||||
createRecoveryLog,
|
||||
installDeliveryQueueTmpDirHooks,
|
||||
} from "./delivery-queue.test-helpers.js";
|
||||
|
||||
let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads;
|
||||
|
||||
const blockReplyCompletionRetention = {
|
||||
idPrefix: "block-reply:v1:",
|
||||
maxAgeMs: 24 * 60 * 60_000,
|
||||
maxEntries: 2_000,
|
||||
} as const;
|
||||
|
||||
describe("deliverOutboundPayloads queue integration: block intent recovery", () => {
|
||||
const fixtures = installDeliveryQueueTmpDirHooks();
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ deliverOutboundPayloads } = await import("./deliver.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fixtures.tmpDir();
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForQueueTest }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("recovers one pending block intent once and dedupes completed producer replays", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:restart-dedupe";
|
||||
await enqueueDeliveryOnce(
|
||||
{
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "durable background update" }],
|
||||
queuePolicy: "required",
|
||||
completionRetention: blockReplyCompletionRetention,
|
||||
},
|
||||
deliveryIntentId,
|
||||
tmpDir,
|
||||
);
|
||||
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "recovered-block-message" });
|
||||
const deliver = vi.fn<DeliverFn>(async (params) =>
|
||||
deliverOutboundPayloads({ ...params, deps: { matrix: sendMatrix } }),
|
||||
);
|
||||
|
||||
await drainMatrixReconnect({ deliver, stateDir: tmpDir });
|
||||
await recoverPendingDeliveries({
|
||||
cfg: {} as OpenClawConfig,
|
||||
deliver,
|
||||
log: createRecoveryLog(),
|
||||
stateDir: tmpDir,
|
||||
});
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "regenerated duplicate" }],
|
||||
deps: { matrix: sendMatrix },
|
||||
queuePolicy: "required",
|
||||
deliveryIntentId,
|
||||
completionRetention: blockReplyCompletionRetention,
|
||||
reusePendingDeliveryIntent: true,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
expect(sendMatrix).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir),
|
||||
).toBe("completed");
|
||||
});
|
||||
});
|
||||
@@ -503,9 +503,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
|
||||
expect(
|
||||
getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir),
|
||||
).toBe("completed");
|
||||
await expect(deliverOutboundPayloads(params)).rejects.toThrow(
|
||||
`Stable delivery intent is already queued: ${deliveryIntentId}`,
|
||||
);
|
||||
await expect(deliverOutboundPayloads(params)).resolves.toEqual([]);
|
||||
expect(sendMatrix).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -532,9 +530,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
|
||||
expect(
|
||||
getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir),
|
||||
).toBe("completed");
|
||||
await expect(deliverOutboundPayloads(params)).rejects.toThrow(
|
||||
`Stable delivery intent is already queued: ${deliveryIntentId}`,
|
||||
);
|
||||
await expect(deliverOutboundPayloads(params)).resolves.toEqual([]);
|
||||
expect(sendMatrix).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -575,9 +571,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
|
||||
expect(sendMatrix).toHaveBeenCalledOnce();
|
||||
resolveSend({ messageId: "concurrent-stable-message" });
|
||||
await expect(first).resolves.toMatchObject([{ messageId: "concurrent-stable-message" }]);
|
||||
await expect(concurrentReplay).rejects.toThrow(
|
||||
`Stable delivery intent is already queued: ${deliveryIntentId}`,
|
||||
);
|
||||
await expect(concurrentReplay).resolves.toEqual([]);
|
||||
expect(
|
||||
getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir),
|
||||
).toBe("completed");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
// Codex App Server Protocol Source tests cover codex app server protocol source script behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -139,6 +140,89 @@ version = "9.9.9"
|
||||
);
|
||||
});
|
||||
|
||||
it("requires HEAD to equal the peeled exact-version tag", async () => {
|
||||
const repoRoot = createTempDir("openclaw-protocol-version-root-");
|
||||
const codexRepo = createTempDir("openclaw-protocol-version-codex-");
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true });
|
||||
fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "extensions/codex/package.json"),
|
||||
JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codexRepo, "codex-rs/Cargo.toml"),
|
||||
'[workspace.package]\nversion = "0.149.0"\n',
|
||||
);
|
||||
for (const args of [
|
||||
["init"],
|
||||
["config", "user.name", "OpenClaw Test"],
|
||||
["config", "user.email", "test@example.invalid"],
|
||||
["add", "codex-rs/Cargo.toml"],
|
||||
["commit", "-m", "tagged source"],
|
||||
["tag", "rust-v0.149.0"],
|
||||
]) {
|
||||
expect(spawnSync("git", args, { cwd: codexRepo }).status).toBe(0);
|
||||
}
|
||||
|
||||
await expect(
|
||||
validateCodexProtocolSourceVersion({ codexRepo, repoRoot }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fs.writeFileSync(path.join(codexRepo, "README.md"), "later commit\n");
|
||||
expect(spawnSync("git", ["add", "README.md"], { cwd: codexRepo }).status).toBe(0);
|
||||
expect(spawnSync("git", ["commit", "-m", "later source"], { cwd: codexRepo }).status).toBe(0);
|
||||
await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow(
|
||||
/does not match peeled rust-v0\.149\.0 commit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a matching-version checkout without the exact version tag", async () => {
|
||||
const repoRoot = createTempDir("openclaw-protocol-version-root-");
|
||||
const codexRepo = createTempDir("openclaw-protocol-version-codex-");
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true });
|
||||
fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "extensions/codex/package.json"),
|
||||
JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codexRepo, "codex-rs/Cargo.toml"),
|
||||
'[workspace.package]\nversion = "0.149.0"\n',
|
||||
);
|
||||
for (const args of [
|
||||
["init"],
|
||||
["config", "user.name", "OpenClaw Test"],
|
||||
["config", "user.email", "test@example.invalid"],
|
||||
["add", "codex-rs/Cargo.toml"],
|
||||
["commit", "-m", "untagged source"],
|
||||
]) {
|
||||
expect(spawnSync("git", args, { cwd: codexRepo }).status).toBe(0);
|
||||
}
|
||||
|
||||
await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow(
|
||||
/Could not resolve Codex protocol source refs\/tags\/rust-v0\.149\.0/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports git command failure for a matching-version non-repository", async () => {
|
||||
const repoRoot = createTempDir("openclaw-protocol-version-root-");
|
||||
const codexRepo = createTempDir("openclaw-protocol-version-codex-");
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true });
|
||||
fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "extensions/codex/package.json"),
|
||||
JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codexRepo, "codex-rs/Cargo.toml"),
|
||||
'[workspace.package]\nversion = "0.149.0"\n',
|
||||
);
|
||||
|
||||
await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow(
|
||||
/Could not resolve Codex protocol source HEAD/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the upstream ignored fixture test with explicit schema env", () => {
|
||||
expect(
|
||||
buildCodexProtocolFixtureCommand("/codex/codex-rs/Cargo.toml", "/tmp/protocol", {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user