mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
3 Commits
v2026.6.10
...
feat/codex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03a4a3e7ef | ||
|
|
f64112325a | ||
|
|
45b948634b |
@@ -1,2 +1,2 @@
|
||||
cf29066e9465cb5ac1387d1d482d0939b9176220ecc69964da9af1a471939269 plugin-sdk-api-baseline.json
|
||||
ab43993cf713a96b191c55cf89bb215c18ecdc2d8edf50f31369ce3b162c56e3 plugin-sdk-api-baseline.jsonl
|
||||
e94933dd5638231fc6132e1a15e352d6c50736973119467a2a538033f5a48d2c plugin-sdk-api-baseline.json
|
||||
499d8db081d97fbbed69bf606567efb0bf1a8e486ab390c76ec518dab748a299 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -211,7 +211,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
|
||||
| `plugin-sdk/agent-harness-task-runtime` | Generic task lifecycle and completion delivery helpers for harness-backed agents using a host-issued task scope |
|
||||
| `plugin-sdk/codex-mcp-projection` | Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Private bundled Codex helper for native task mirror/runtime wiring; not for third-party plugins |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Private bundled Codex helper for native task mirror, runtime wiring, and native subagent lifecycle hook emission; not for third-party plugins |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/matrix` | Deprecated Matrix compatibility facade for older third-party channel packages; new plugins should import `plugin-sdk/run-command` directly |
|
||||
| `plugin-sdk/mattermost` | Deprecated Mattermost compatibility facade for older third-party channel packages; new plugins should import generic SDK subpaths directly |
|
||||
@@ -379,7 +379,7 @@ and pairing-path families.
|
||||
| Subpath | Owner and purpose |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/codex-mcp-projection` | Bundled Codex plugin helper for projecting user MCP server config into Codex app-server thread config |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state and lifecycle hooks |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -68,6 +68,8 @@ function createRuntime() {
|
||||
path: "direct" as const,
|
||||
}),
|
||||
),
|
||||
emitAgentHarnessSubagentSpawnedHook: vi.fn(async () => {}),
|
||||
emitAgentHarnessSubagentEndedHook: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
type AgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRecord,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import {
|
||||
emitAgentHarnessSubagentEndedHook,
|
||||
emitAgentHarnessSubagentSpawnedHook,
|
||||
} from "openclaw/plugin-sdk/codex-native-task-runtime";
|
||||
import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
@@ -32,6 +36,8 @@ import { isJsonObject } from "./protocol.js";
|
||||
type NativeSubagentMonitorRuntime = {
|
||||
createAgentHarnessTaskRuntime: typeof createAgentHarnessTaskRuntime;
|
||||
deliverAgentHarnessTaskCompletion: typeof deliverAgentHarnessTaskCompletion;
|
||||
emitAgentHarnessSubagentSpawnedHook: typeof emitAgentHarnessSubagentSpawnedHook;
|
||||
emitAgentHarnessSubagentEndedHook: typeof emitAgentHarnessSubagentEndedHook;
|
||||
};
|
||||
|
||||
type ParentState = {
|
||||
@@ -82,6 +88,8 @@ const RECENT_TERMINAL_TASK_RECONCILE_GRACE_MS = 60_000;
|
||||
const defaultRuntime: NativeSubagentMonitorRuntime = {
|
||||
createAgentHarnessTaskRuntime,
|
||||
deliverAgentHarnessTaskCompletion,
|
||||
emitAgentHarnessSubagentSpawnedHook,
|
||||
emitAgentHarnessSubagentEndedHook,
|
||||
};
|
||||
|
||||
const monitors = new WeakMap<CodexAppServerClient, CodexNativeSubagentMonitor>();
|
||||
@@ -221,9 +229,14 @@ export class CodexNativeSubagentMonitor {
|
||||
{
|
||||
parentThreadId: state.parentThreadId,
|
||||
requesterSessionKey: state.requesterSessionKey,
|
||||
taskRuntimeScope: state.taskRuntimeScope,
|
||||
agentId: state.agentId,
|
||||
},
|
||||
state.taskRuntime,
|
||||
{
|
||||
emitSubagentSpawnedHook: this.runtime.emitAgentHarnessSubagentSpawnedHook,
|
||||
emitSubagentEndedHook: this.runtime.emitAgentHarnessSubagentEndedHook,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentHarnessTaskRuntimeScope } from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
codexNativeSubagentRunId,
|
||||
CodexNativeSubagentTaskMirror,
|
||||
type TaskLifecycleHookRuntime,
|
||||
type TaskLifecycleRuntime,
|
||||
} from "./native-subagent-task-mirror.js";
|
||||
|
||||
@@ -13,6 +15,17 @@ function createRuntime() {
|
||||
} as unknown as TaskLifecycleRuntime;
|
||||
}
|
||||
|
||||
function createHookRuntime() {
|
||||
return {
|
||||
emitSubagentSpawnedHook: vi.fn(async () => {}),
|
||||
emitSubagentEndedHook: vi.fn(async () => {}),
|
||||
} satisfies TaskLifecycleHookRuntime;
|
||||
}
|
||||
|
||||
function createTaskScope(requesterSessionKey = "agent:main:main") {
|
||||
return { requesterSessionKey } as AgentHarnessTaskRuntimeScope;
|
||||
}
|
||||
|
||||
describe("CodexNativeSubagentTaskMirror", () => {
|
||||
it("creates a silent task-registry task for a native Codex subagent thread", () => {
|
||||
const runtime = createRuntime();
|
||||
@@ -136,6 +149,87 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits subagent_spawned when a native Codex thread is first mirrored", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const taskRuntimeScope = createTaskScope("agent:main:discord:channel:C123");
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope,
|
||||
agentId: "main",
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
thread: {
|
||||
id: "child-thread",
|
||||
preview: "inspect the repo",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "parent-thread",
|
||||
depth: 1,
|
||||
agent_nickname: "research",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookRuntime.emitSubagentSpawnedHook).toHaveBeenCalledTimes(1);
|
||||
expect(hookRuntime.emitSubagentSpawnedHook).toHaveBeenCalledWith({
|
||||
scope: taskRuntimeScope,
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
threadRequested: false,
|
||||
mode: "run",
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates subagent_spawned hook emission for repeated thread mirrors", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
const notification = {
|
||||
method: "thread/started",
|
||||
params: {
|
||||
thread: {
|
||||
id: "child-thread",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "parent-thread",
|
||||
depth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
mirror.handleNotification(notification);
|
||||
mirror.handleNotification(notification);
|
||||
|
||||
expect(hookRuntime.emitSubagentSpawnedHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps Codex thread status changes onto the mirrored task run", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
@@ -181,6 +275,89 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits subagent_ended when native Codex thread status becomes terminal", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const taskRuntimeScope = createTaskScope();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope,
|
||||
now: () => 30_000,
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
});
|
||||
mirror.handleNotification({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "failed-child",
|
||||
status: { type: "systemError" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(1, {
|
||||
scope: taskRuntimeScope,
|
||||
runId: "codex-thread:child-thread",
|
||||
targetSessionKey: "codex-thread:child-thread",
|
||||
reason: "subagent-complete",
|
||||
outcome: "ok",
|
||||
endedAt: 30_000,
|
||||
error: undefined,
|
||||
});
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(2, {
|
||||
scope: taskRuntimeScope,
|
||||
runId: "codex-thread:failed-child",
|
||||
targetSessionKey: "codex-thread:failed-child",
|
||||
reason: "subagent-error",
|
||||
outcome: "error",
|
||||
endedAt: 30_000,
|
||||
error: "Codex app-server reported a system error for the native subagent thread.",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits subagent_ended only once for a native Codex child run", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
now: () => 30_000,
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
});
|
||||
mirror.handleNotification({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "systemError" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates and updates tasks from Codex collab agent item state", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
@@ -258,6 +435,46 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits subagent_spawned from Codex collab spawn item state", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const taskRuntimeScope = createTaskScope();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope,
|
||||
agentId: "main",
|
||||
now: () => 40_000,
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "spawnAgent",
|
||||
senderThreadId: "parent-thread",
|
||||
receiverThreadIds: ["child-thread"],
|
||||
prompt: "write the proof file",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookRuntime.emitSubagentSpawnedHook).toHaveBeenCalledWith({
|
||||
scope: taskRuntimeScope,
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
label: "Codex subagent",
|
||||
threadRequested: false,
|
||||
mode: "run",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the notification thread id when collab agent items omit sender thread id", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
@@ -622,4 +839,89 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
terminalSummary: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits subagent_ended for completed, blocked, failed, interrupted, and shutdown collab statuses", () => {
|
||||
const runtime = createRuntime();
|
||||
const hookRuntime = createHookRuntime();
|
||||
const taskRuntimeScope = createTaskScope();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope,
|
||||
now: () => 61_000,
|
||||
},
|
||||
runtime,
|
||||
hookRuntime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "spawnAgent",
|
||||
senderThreadId: "parent-thread",
|
||||
agentsStates: {
|
||||
completedChild: { status: "completed", message: "done" },
|
||||
blockedChild: { status: "blocked", message: "needs input" },
|
||||
failedChild: { status: "failed", message: "boom" },
|
||||
interruptedChild: { status: "interrupted", message: "stopped by parent" },
|
||||
shutdownChild: { status: "shutdown", message: "app-server stopped" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
scope: taskRuntimeScope,
|
||||
runId: "codex-thread:completedChild",
|
||||
targetSessionKey: "codex-thread:completedChild",
|
||||
reason: "subagent-complete",
|
||||
outcome: "ok",
|
||||
endedAt: 61_000,
|
||||
}),
|
||||
);
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:blockedChild",
|
||||
targetSessionKey: "codex-thread:blockedChild",
|
||||
reason: "subagent-complete",
|
||||
outcome: "ok",
|
||||
}),
|
||||
);
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:failedChild",
|
||||
targetSessionKey: "codex-thread:failedChild",
|
||||
reason: "subagent-error",
|
||||
outcome: "error",
|
||||
error: "boom",
|
||||
}),
|
||||
);
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:interruptedChild",
|
||||
targetSessionKey: "codex-thread:interruptedChild",
|
||||
reason: "subagent-killed",
|
||||
outcome: "killed",
|
||||
error: "stopped by parent",
|
||||
}),
|
||||
);
|
||||
expect(hookRuntime.emitSubagentEndedHook).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:shutdownChild",
|
||||
targetSessionKey: "codex-thread:shutdownChild",
|
||||
reason: "subagent-killed",
|
||||
outcome: "killed",
|
||||
error: "app-server stopped",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import type { AgentHarnessTaskRuntime } from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import type {
|
||||
AgentHarnessTaskRuntime,
|
||||
AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import type {
|
||||
emitAgentHarnessSubagentEndedHook,
|
||||
emitAgentHarnessSubagentSpawnedHook,
|
||||
SubagentLifecycleEndedOutcome,
|
||||
SubagentLifecycleEndedReason,
|
||||
} from "openclaw/plugin-sdk/codex-native-task-runtime";
|
||||
import {
|
||||
resolveAgentHarnessFailedSubagentEnd,
|
||||
resolveAgentHarnessKilledSubagentEnd,
|
||||
resolveAgentHarnessSucceededSubagentEnd,
|
||||
} from "openclaw/plugin-sdk/codex-native-task-runtime";
|
||||
import { CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX } from "./native-subagent-task-ids.js";
|
||||
import type {
|
||||
CodexServerNotification,
|
||||
@@ -18,9 +32,23 @@ export type TaskLifecycleRuntime = Pick<
|
||||
"createRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export type TaskLifecycleHookRuntime = {
|
||||
emitSubagentSpawnedHook: typeof emitAgentHarnessSubagentSpawnedHook;
|
||||
emitSubagentEndedHook: typeof emitAgentHarnessSubagentEndedHook;
|
||||
};
|
||||
|
||||
type EndedHookParams = {
|
||||
runId: string;
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
outcome: SubagentLifecycleEndedOutcome;
|
||||
endedAt: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CodexNativeSubagentTaskMirrorParams = {
|
||||
parentThreadId: string;
|
||||
requesterSessionKey?: string;
|
||||
taskRuntimeScope?: AgentHarnessTaskRuntimeScope;
|
||||
agentId?: string;
|
||||
now?: () => number;
|
||||
};
|
||||
@@ -28,11 +56,13 @@ export type CodexNativeSubagentTaskMirrorParams = {
|
||||
export class CodexNativeSubagentTaskMirror {
|
||||
private readonly mirroredThreadIds = new Set<string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly endedHookRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly params: CodexNativeSubagentTaskMirrorParams,
|
||||
private readonly runtime: TaskLifecycleRuntime,
|
||||
private readonly hookRuntime?: TaskLifecycleHookRuntime,
|
||||
) {
|
||||
this.now = params.now ?? Date.now;
|
||||
}
|
||||
@@ -94,6 +124,11 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: this.now(),
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
this.emitSpawnedHook({
|
||||
runId,
|
||||
childSessionKey: runId,
|
||||
label,
|
||||
});
|
||||
this.applyStatus(threadId, thread.status);
|
||||
}
|
||||
|
||||
@@ -133,6 +168,11 @@ export class CodexNativeSubagentTaskMirror {
|
||||
progressSummary: "Codex native subagent is idle.",
|
||||
terminalSummary: "Codex native subagent finished.",
|
||||
});
|
||||
this.emitEndedHook({
|
||||
runId,
|
||||
endedAt: eventAt,
|
||||
...resolveAgentHarnessSucceededSubagentEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (statusType === "systemError") {
|
||||
@@ -146,6 +186,12 @@ export class CodexNativeSubagentTaskMirror {
|
||||
progressSummary: "Codex native subagent hit a system error.",
|
||||
terminalSummary: "Codex native subagent failed.",
|
||||
});
|
||||
this.emitEndedHook({
|
||||
runId,
|
||||
endedAt: eventAt,
|
||||
error: "Codex app-server reported a system error for the native subagent thread.",
|
||||
...resolveAgentHarnessFailedSubagentEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (statusType === "notLoaded") {
|
||||
@@ -232,6 +278,11 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: createdAt,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
this.emitSpawnedHook({
|
||||
runId,
|
||||
childSessionKey: runId,
|
||||
label: "Codex subagent",
|
||||
});
|
||||
}
|
||||
|
||||
private applyCollabAgentStatus(
|
||||
@@ -270,6 +321,11 @@ export class CodexNativeSubagentTaskMirror {
|
||||
progressSummary: trimOptional(message) ?? "Codex native subagent completed.",
|
||||
terminalSummary: trimOptional(message) ?? "Codex native subagent finished.",
|
||||
});
|
||||
this.emitEndedHook({
|
||||
runId,
|
||||
endedAt: eventAt,
|
||||
...resolveAgentHarnessSucceededSubagentEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (normalizedStatus === "blocked") {
|
||||
@@ -283,8 +339,18 @@ export class CodexNativeSubagentTaskMirror {
|
||||
terminalSummary: trimOptional(message) ?? "Codex native subagent blocked.",
|
||||
terminalOutcome: "blocked",
|
||||
});
|
||||
this.emitEndedHook({
|
||||
runId,
|
||||
endedAt: eventAt,
|
||||
...resolveAgentHarnessSucceededSubagentEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const terminalEnd =
|
||||
normalizedStatus === "interrupted" || normalizedStatus === "shutdown"
|
||||
? resolveAgentHarnessKilledSubagentEnd()
|
||||
: resolveAgentHarnessFailedSubagentEnd();
|
||||
const error = trimOptional(message) ?? `Codex native subagent status: ${normalizedStatus}`;
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
@@ -294,10 +360,50 @@ export class CodexNativeSubagentTaskMirror {
|
||||
: "failed",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: trimOptional(message) ?? `Codex native subagent status: ${normalizedStatus}`,
|
||||
error,
|
||||
progressSummary: trimOptional(message) ?? `Codex native subagent ${normalizedStatus}.`,
|
||||
terminalSummary: trimOptional(message) ?? "Codex native subagent did not complete.",
|
||||
});
|
||||
this.emitEndedHook({
|
||||
runId,
|
||||
endedAt: eventAt,
|
||||
error,
|
||||
...terminalEnd,
|
||||
});
|
||||
}
|
||||
|
||||
private emitSpawnedHook(params: { runId: string; childSessionKey: string; label: string }): void {
|
||||
if (!this.params.taskRuntimeScope || !this.hookRuntime) {
|
||||
return;
|
||||
}
|
||||
void this.hookRuntime.emitSubagentSpawnedHook({
|
||||
scope: this.params.taskRuntimeScope,
|
||||
runId: params.runId,
|
||||
childSessionKey: params.childSessionKey,
|
||||
agentId: this.params.agentId,
|
||||
label: params.label,
|
||||
threadRequested: false,
|
||||
mode: "run",
|
||||
});
|
||||
}
|
||||
|
||||
private emitEndedHook(params: EndedHookParams): void {
|
||||
if (!this.params.taskRuntimeScope || !this.hookRuntime) {
|
||||
return;
|
||||
}
|
||||
if (this.endedHookRunIds.has(params.runId)) {
|
||||
return;
|
||||
}
|
||||
this.endedHookRunIds.add(params.runId);
|
||||
void this.hookRuntime.emitSubagentEndedHook({
|
||||
scope: this.params.taskRuntimeScope,
|
||||
runId: params.runId,
|
||||
targetSessionKey: params.runId,
|
||||
reason: params.reason,
|
||||
outcome: params.outcome,
|
||||
endedAt: params.endedAt,
|
||||
error: params.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,11 @@ describe("agent-harness-task-runtime", () => {
|
||||
vi.mocked(listTaskRecords).mockReturnValue([]);
|
||||
});
|
||||
|
||||
function createScope(requesterSessionKey = "agent:main:channel:C123") {
|
||||
return createAgentHarnessTaskRuntimeScope({ requesterSessionKey });
|
||||
function createScope(
|
||||
requesterSessionKey = "agent:main:channel:C123",
|
||||
requesterOrigin?: Parameters<typeof createAgentHarnessTaskRuntimeScope>[0]["requesterOrigin"],
|
||||
) {
|
||||
return createAgentHarnessTaskRuntimeScope({ requesterSessionKey, requesterOrigin });
|
||||
}
|
||||
|
||||
it("scopes task lifecycle mutations to the owning requester session", () => {
|
||||
|
||||
124
src/plugin-sdk/codex-native-task-runtime.test.ts
Normal file
124
src/plugin-sdk/codex-native-task-runtime.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { createAgentHarnessTaskRuntimeScope } from "../tasks/agent-harness-task-runtime-scope.js";
|
||||
import {
|
||||
emitAgentHarnessSubagentEndedHook,
|
||||
emitAgentHarnessSubagentSpawnedHook,
|
||||
} from "./codex-native-task-runtime.js";
|
||||
|
||||
vi.mock("../agents/subagent-announce-delivery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../agents/subagent-announce-delivery.js")>();
|
||||
return {
|
||||
...actual,
|
||||
isInternalAnnounceRequesterSession: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("codex-native-task-runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
|
||||
});
|
||||
|
||||
function createScope(
|
||||
requesterSessionKey = "agent:main:channel:C123",
|
||||
requesterOrigin?: Parameters<typeof createAgentHarnessTaskRuntimeScope>[0]["requesterOrigin"],
|
||||
) {
|
||||
return createAgentHarnessTaskRuntimeScope({ requesterSessionKey, requesterOrigin });
|
||||
}
|
||||
|
||||
it("emits subagent_spawned with requester metadata from the harness scope", async () => {
|
||||
const runSubagentSpawned = vi.fn(async () => {});
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue({
|
||||
hasHooks: (hookName: string) => hookName === "subagent_spawned",
|
||||
runSubagentSpawned,
|
||||
} as never);
|
||||
const scope = createScope("agent:main:discord:channel:C123", {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:C123",
|
||||
threadId: "456",
|
||||
});
|
||||
|
||||
await emitAgentHarnessSubagentSpawnedHook({
|
||||
scope,
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
threadRequested: false,
|
||||
mode: "run",
|
||||
});
|
||||
|
||||
expect(runSubagentSpawned).toHaveBeenCalledWith(
|
||||
{
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:C123",
|
||||
threadId: "456",
|
||||
},
|
||||
threadRequested: false,
|
||||
mode: "run",
|
||||
},
|
||||
{
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("emits subagent_ended with requester account metadata and swallows hook failures", async () => {
|
||||
const runSubagentEnded = vi.fn(async () => {
|
||||
throw new Error("hook failed");
|
||||
});
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue({
|
||||
hasHooks: (hookName: string) => hookName === "subagent_ended",
|
||||
runSubagentEnded,
|
||||
} as never);
|
||||
const scope = createScope("agent:main:discord:channel:C123", {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:C123",
|
||||
});
|
||||
|
||||
await expect(
|
||||
emitAgentHarnessSubagentEndedHook({
|
||||
scope,
|
||||
runId: "codex-thread:child-thread",
|
||||
targetSessionKey: "codex-thread:child-thread",
|
||||
reason: "subagent-error",
|
||||
outcome: "error",
|
||||
endedAt: 1_234,
|
||||
error: "boom",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(runSubagentEnded).toHaveBeenCalledWith(
|
||||
{
|
||||
targetSessionKey: "codex-thread:child-thread",
|
||||
targetKind: "subagent",
|
||||
reason: "subagent-error",
|
||||
accountId: "work",
|
||||
runId: "codex-thread:child-thread",
|
||||
endedAt: 1_234,
|
||||
outcome: "error",
|
||||
error: "boom",
|
||||
},
|
||||
{
|
||||
runId: "codex-thread:child-thread",
|
||||
childSessionKey: "codex-thread:child-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,30 @@
|
||||
// task registry without promoting detached task mutation helpers to the public
|
||||
// plugin SDK.
|
||||
|
||||
import {
|
||||
isInternalAnnounceRequesterSession,
|
||||
loadRequesterSessionEntry,
|
||||
} from "../agents/subagent-announce-delivery.js";
|
||||
import { resolveAnnounceOrigin } from "../agents/subagent-announce-origin.js";
|
||||
import {
|
||||
SUBAGENT_ENDED_OUTCOME_ERROR,
|
||||
SUBAGENT_ENDED_OUTCOME_KILLED,
|
||||
SUBAGENT_ENDED_OUTCOME_OK,
|
||||
SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
SUBAGENT_ENDED_REASON_ERROR,
|
||||
SUBAGENT_ENDED_REASON_KILLED,
|
||||
SUBAGENT_TARGET_KIND_SUBAGENT,
|
||||
type SubagentLifecycleEndedOutcome,
|
||||
type SubagentLifecycleEndedReason,
|
||||
} from "../agents/subagent-lifecycle-events.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
assertAgentHarnessTaskRuntimeScope,
|
||||
type AgentHarnessTaskRuntimeScope,
|
||||
} from "../tasks/agent-harness-task-runtime-scope.js";
|
||||
|
||||
export {
|
||||
CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX,
|
||||
CODEX_NATIVE_SUBAGENT_RUNTIME,
|
||||
@@ -15,3 +39,132 @@ export {
|
||||
finalizeTaskRunByRunId,
|
||||
recordTaskRunProgressByRunId,
|
||||
} from "../tasks/detached-task-runtime.js";
|
||||
|
||||
export type { AgentHarnessTaskRuntimeScope };
|
||||
export type { SubagentLifecycleEndedOutcome, SubagentLifecycleEndedReason };
|
||||
|
||||
const log = createSubsystemLogger("plugin-sdk/codex-native-task-runtime");
|
||||
|
||||
export async function emitAgentHarnessSubagentSpawnedHook(params: {
|
||||
scope: AgentHarnessTaskRuntimeScope;
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
mode: "run" | "session";
|
||||
threadRequested: boolean;
|
||||
}): Promise<void> {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (!hookRunner?.hasHooks("subagent_spawned")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const scope = assertAgentHarnessTaskRuntimeScope(params.scope);
|
||||
const requesterOrigin = resolveAgentHarnessRequesterOrigin(scope);
|
||||
await hookRunner.runSubagentSpawned(
|
||||
{
|
||||
runId: params.runId,
|
||||
childSessionKey: params.childSessionKey,
|
||||
agentId: params.agentId?.trim() || "unknown",
|
||||
label: normalizeOptionalString(params.label),
|
||||
requester: {
|
||||
channel: requesterOrigin?.channel,
|
||||
accountId: requesterOrigin?.accountId,
|
||||
to: requesterOrigin?.to,
|
||||
threadId: requesterOrigin?.threadId,
|
||||
},
|
||||
threadRequested: params.threadRequested,
|
||||
mode: params.mode,
|
||||
},
|
||||
{
|
||||
runId: params.runId,
|
||||
childSessionKey: params.childSessionKey,
|
||||
requesterSessionKey: scope.requesterSessionKey,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("failed to emit Codex native subagent_spawned hook", {
|
||||
runId: params.runId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitAgentHarnessSubagentEndedHook(params: {
|
||||
scope: AgentHarnessTaskRuntimeScope;
|
||||
runId: string;
|
||||
targetSessionKey: string;
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
outcome: SubagentLifecycleEndedOutcome;
|
||||
endedAt?: number;
|
||||
error?: string;
|
||||
}): Promise<void> {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (!hookRunner?.hasHooks("subagent_ended")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const scope = assertAgentHarnessTaskRuntimeScope(params.scope);
|
||||
const requesterOrigin = resolveAgentHarnessRequesterOrigin(scope);
|
||||
await hookRunner.runSubagentEnded(
|
||||
{
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
targetKind: SUBAGENT_TARGET_KIND_SUBAGENT,
|
||||
reason: params.reason,
|
||||
accountId: requesterOrigin?.accountId,
|
||||
runId: params.runId,
|
||||
endedAt: params.endedAt,
|
||||
outcome: params.outcome,
|
||||
error: params.error,
|
||||
},
|
||||
{
|
||||
runId: params.runId,
|
||||
childSessionKey: params.targetSessionKey,
|
||||
requesterSessionKey: scope.requesterSessionKey,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("failed to emit Codex native subagent_ended hook", {
|
||||
runId: params.runId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentHarnessSucceededSubagentEnd(): {
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
outcome: SubagentLifecycleEndedOutcome;
|
||||
} {
|
||||
return {
|
||||
reason: SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
outcome: SUBAGENT_ENDED_OUTCOME_OK,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentHarnessFailedSubagentEnd(): {
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
outcome: SubagentLifecycleEndedOutcome;
|
||||
} {
|
||||
return {
|
||||
reason: SUBAGENT_ENDED_REASON_ERROR,
|
||||
outcome: SUBAGENT_ENDED_OUTCOME_ERROR,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentHarnessKilledSubagentEnd(): {
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
outcome: SubagentLifecycleEndedOutcome;
|
||||
} {
|
||||
return {
|
||||
reason: SUBAGENT_ENDED_REASON_KILLED,
|
||||
outcome: SUBAGENT_ENDED_OUTCOME_KILLED,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAgentHarnessRequesterOrigin(scope: AgentHarnessTaskRuntimeScope) {
|
||||
if (isInternalAnnounceRequesterSession(scope.requesterSessionKey)) {
|
||||
return scope.requesterOrigin;
|
||||
}
|
||||
const { entry } = loadRequesterSessionEntry(scope.requesterSessionKey);
|
||||
return resolveAnnounceOrigin(entry, scope.requesterOrigin);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user