Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ec97c8982b fix(codex): keep control cards from claiming child routes
Only spawn announcements establish child ownership. Wait and close results update existing children, while failed resume attempts retain their own visible result without replacing a previous child outcome.
2026-10-30 06:45:18 +00:00
paseo-bot[bot] fca6e24fc0 fix(codex): restore outcomes after child follow-ups
Replay must distinguish an earlier terminal outcome from the next outcome after an accepted follow-up. Keep that distinction with each projected child, and normalize wait and close results consistently for original spawn cards and standalone control cards.
2026-10-30 06:27:32 +00:00
paseo-bot[bot] 9615afb837 fix(codex): preserve follow-up instructions in parent history
Follow-up submissions carry user-visible instructions and must keep their own cards. Their sampled child states describe submission time, so only the submission result settles the card while child lifecycle events continue to own child status.
2026-10-30 06:16:48 +00:00
paseo-bot[bot] a98fa27901 test(server): collect fixture garbage before Git filter measurement 2026-10-30 06:03:39 +00:00
paseo-bot[bot] 5e7c7e609e fix(codex): leave follow-up status to child lifecycle events 2026-10-30 06:03:39 +00:00
paseo-bot[bot] 8207f697c5 fix(codex): preserve final errors and settle rendered wait cards 2026-10-30 06:03:39 +00:00
paseo-bot[bot] a02fa39125 fix(codex): settle closed children independently of prior errors
Codex derives a close record's status from the child's previous state,
so closing an errored child produces a failed record even after shutdown.
Treat close as a terminal-preserving child event rather than a successful
control-card status, retaining earlier completion when a close is stale.
2026-10-30 06:03:39 +00:00
paseo-bot[bot] 6041e86dad fix(codex): fold live control starts into existing subagents
A wait starts without child states. Recognize it from its receivers so
live handling consumes both lifecycle notifications, as replay does,
instead of leaving an extra running control card behind. Normalize
child status directly with the mapper's existing child-status rule.
2026-10-30 06:03:39 +00:00
cbfb29c5fa fix(codex): settle children closed before they finish
A successful close records the child's previous running state. Replaying
that snapshot must stop counting the child as working. Exercise replay
and live control updates through the session interface instead of calling
private notification methods.

Co-authored-by: geeknonerd <14756651+geeknonerd@users.noreply.github.com>
Co-authored-by: altenli <liqian115@gmail.com>
2026-10-30 06:03:39 +00:00
popstk dc23fe6545 fix(codex): reconcile nullable collaboration results with spawned agents 2026-10-30 06:03:39 +00:00
22 changed files with 685 additions and 264 deletions
-4
View File
@@ -11,10 +11,6 @@ Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
Fixed:
- Keep an open agent on its current provider process when a session-open plugin reloads without changing env, tools, or MCP servers ([#8269](https://github.com/getpaseo/paseo/pull/8269))
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
-4
View File
@@ -11,10 +11,6 @@
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
修复:
- 会话打开钩子重载后如果没有改变 env、工具或 MCP 服务器,就保持当前 provider 进程,不再为下一条消息关掉它 ([#8269](https://github.com/getpaseo/paseo/pull/8269))
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "default",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
-35
View File
@@ -59,38 +59,3 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -23,7 +22,14 @@ import {
type ComponentType,
type RefObject,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Modal, Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import {
View,
Text,
Pressable,
Modal,
ScrollView,
Platform,
StatusBar,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
View,
-13
View File
@@ -1,13 +0,0 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
export function Modal(props: ModalProps) {
return <NativeModal supportedOrientations={orientations} {...props} />;
}
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
Children,
cloneElement,
@@ -17,6 +16,7 @@ import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
View,
type PressableProps,
+8 -2
View File
@@ -1,7 +1,13 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
@@ -7,7 +7,6 @@ import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { basename, resolve } from "node:path";
import { stat } from "node:fs/promises";
import {
@@ -103,32 +102,6 @@ import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
interface SessionOpenContribution {
env: Record<string, string>;
tools: Array<{ pluginId: string; pluginInstanceId: string; name: string }>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
function sessionOpenContribution(input: {
purpose: PluginSessionOpenRequest["purpose"];
env: Record<string, string> | undefined;
tools: readonly AttachedAgentTool[];
mcpServers: AgentSessionConfig["mcpServers"];
}): SessionOpenContribution {
const interactive = input.purpose === "interactive";
return {
env: { ...input.env },
tools: interactive
? input.tools.map((tool) => ({
pluginId: tool.pluginId,
pluginInstanceId: tool.pluginInstanceId,
name: tool.name,
}))
: [],
mcpServers: interactive ? structuredClone(input.mcpServers ?? {}) : {},
};
}
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000;
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
@@ -807,7 +780,6 @@ export class AgentManager {
private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly sessionOpenContributions = new Map<string, SessionOpenContribution>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true;
@@ -941,49 +913,11 @@ export class AgentManager {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
const generation = this.sessionOpenGeneration;
// A hook that only observes the open must not kill the provider process.
// Reload only when env, tools, or MCP servers would actually change.
const unchanged = await this.sessionOpenContributionUnchanged(agentId);
if (!this.needsPluginSessionReload(agentId)) return;
if (unchanged && this.sessionOpenGeneration === generation) {
this.sessionOpenGenerations.set(agentId, generation);
return;
}
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private async sessionOpenContributionUnchanged(agentId: string): Promise<boolean> {
const agent = this.getAgent(agentId);
const previous = this.sessionOpenContributions.get(agentId);
const client = agent ? this.clients.get(agent.provider) : undefined;
if (!agent || !previous || !client || !this.pluginLifecycle) return false;
const transformed = await this.pluginLifecycle.before("agent.open.before", {
agentId,
provider: client.provider,
cwd: agent.cwd,
workspaceId: agent.workspaceId ?? null,
reason: "refresh",
purpose: "interactive",
env: { ...agent.launchEnv },
title: await this.resolveInitialPersistedTitle(agentId, agent.config, null),
labels: agent.labels,
tools: [],
mcpServers: {},
});
return isDeepStrictEqual(
previous,
sessionOpenContribution({
purpose: "interactive",
env: transformed.env,
tools: transformed.tools as AttachedAgentTool[],
mcpServers: transformed.mcpServers,
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
@@ -1023,7 +957,6 @@ export class AgentManager {
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.sessionOpenContributions.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
@@ -5633,13 +5566,6 @@ export class AgentManager {
this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
const purpose = opening?.purpose ?? "interactive";
let contribution = sessionOpenContribution({
purpose,
env,
tools: [],
mcpServers: {},
});
if (this.pluginLifecycle) {
const request = {
agentId,
@@ -5647,7 +5573,7 @@ export class AgentManager {
cwd,
workspaceId: opening?.workspaceId ?? null,
reason: opening?.reason ?? "resume",
purpose,
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
@@ -5656,18 +5582,11 @@ export class AgentManager {
};
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
env = transformed.env;
const tools = transformed.tools as AttachedAgentTool[];
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, tools);
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
contribution = sessionOpenContribution({
purpose: request.purpose,
env,
tools,
mcpServers: transformed.mcpServers,
});
}
const context: AgentLaunchContext = {
agentId,
@@ -5699,7 +5618,6 @@ export class AgentManager {
}
}
this.sessionOpenGenerations.set(agentId, generation);
this.sessionOpenContributions.set(agentId, contribution);
return context;
}
@@ -58,6 +58,7 @@ import {
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
normalizeCommandExecutionCommand,
normalizeCollabAgentChildStatus,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import {
@@ -1857,6 +1858,77 @@ function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null
};
}
interface CodexCollabAgentUpdate {
threadId: string;
status: ToolCallTimelineItem["status"];
preservesTerminal: boolean;
}
type CodexCollabAgentControl =
| { kind: "submission"; childThreadIds: string[] }
| { kind: "outcome"; childThreadIds: string[]; updates: CodexCollabAgentUpdate[] };
interface HistoricalCodexSubAgent {
timelineIndex: number;
pendingFollowUp: boolean;
}
function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | null {
const record = toObjectRecord(item);
if (
!record ||
normalizeCodexThreadItemType(typeof record.type === "string" ? record.type : undefined) !==
"collabAgentToolCall" ||
record.tool === "spawnAgent"
) {
return null;
}
const tool = typeof record.tool === "string" ? record.tool : undefined;
const receivers = Array.isArray(record.receiverThreadIds)
? record.receiverThreadIds.filter((id): id is string => typeof id === "string")
: [];
if (tool === "sendInput") {
return record.status === "completed" && record.error == null
? { kind: "submission", childThreadIds: receivers }
: null;
}
if (tool !== "wait" && tool !== "closeAgent") return null;
const outcomeStates = toObjectRecord(record.agentsStates);
const updates = Object.entries(outcomeStates ?? {}).flatMap(
([threadId, value]): CodexCollabAgentUpdate[] => {
const state = toObjectRecord(value);
if (typeof state?.status !== "string") return [];
// Unlike a spawn snapshot, a control result can report the child's final error.
const status = normalizeCollabAgentChildStatus(state.status, tool);
return [
{
threadId,
status,
preservesTerminal: state.status === "shutdown" || record.tool === "closeAgent",
},
];
},
);
return {
kind: "outcome",
childThreadIds: [...new Set([...receivers, ...updates.map((update) => update.threadId)])],
updates,
};
}
function resolveCodexCollabAgentUpdate(
previous: ToolCallTimelineItem["status"],
update: CodexCollabAgentUpdate,
): ToolCallTimelineItem["status"] {
if (
isTerminalSubAgentStatus(previous) &&
(update.preservesTerminal || update.status === "running")
) {
return previous;
}
return update.status;
}
function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean {
return source === "codex_event" && !readCodexSubAgentActivity(item);
}
@@ -1897,7 +1969,7 @@ function updateHistoricalSubAgentActivity(
};
}
function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] {
function readCodexSpawnedSubAgentThreadIds(item: unknown): string[] {
const activity = readCodexSubAgentActivity(item);
if (activity) {
return activity.kind === "started" ? [activity.agentThreadId] : [];
@@ -2060,23 +2132,57 @@ async function threadItemToTimelineEntries(
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
function updateHistoricalCollabAgentCalls(
timeline: PersistedTimelineEntry[],
subAgentsByThreadId: Map<string, HistoricalCodexSubAgent>,
item: unknown,
): boolean {
const control = readCodexCollabAgentControl(item);
if (!control) return false;
if (control.kind === "submission") {
for (const threadId of control.childThreadIds) {
const child = subAgentsByThreadId.get(threadId);
if (child) child.pendingFollowUp = true;
}
return false;
}
for (const update of control.updates) {
const child = subAgentsByThreadId.get(update.threadId);
const existing = child === undefined ? undefined : timeline[child.timelineIndex];
if (!child || existing?.item.type !== "tool_call") continue;
const status = child.pendingFollowUp
? update.status
: resolveCodexCollabAgentUpdate(existing.item.status, update);
child.pendingFollowUp = false;
const toolCall: ToolCallTimelineItem =
status === "failed"
? { ...existing.item, status, error: { message: "Sub-agent failed" } }
: { ...existing.item, status, error: null };
timeline[child.timelineIndex] = { ...existing, item: toolCall };
}
return (
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => subAgentsByThreadId.has(id))
);
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
}): Promise<CodexThreadHistoryProjection> {
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
const subAgentsByThreadId = new Map<string, HistoricalCodexSubAgent>();
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
await projectCodexThreadHistory(params, { timeline, subAgentsByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
const item = timeline[timelineIndex]?.item;
const subAgentRoutes = Array.from(subAgentsByThreadId.entries()).flatMap(
([childThreadId, child]): PersistedSubAgentRoute[] => {
const item = timeline[child.timelineIndex]?.item;
return item?.type === "tool_call" && item.detail.type === "sub_agent"
? [{ childThreadId, toolCall: item }]
: [];
@@ -2089,17 +2195,20 @@ async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
subAgentsByThreadId: Map<string, HistoricalCodexSubAgent>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
const { timeline, subAgentsByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
if (updateHistoricalCollabAgentCalls(timeline, subAgentsByThreadId, item)) {
continue;
}
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
const existingIndex = subAgentsByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
)?.timelineIndex;
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
@@ -2129,8 +2238,11 @@ async function projectCodexThreadHistory(
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
for (const childThreadId of readCodexSpawnedSubAgentThreadIds(item)) {
subAgentsByThreadId.set(childThreadId, {
timelineIndex: timeline.length - 1,
pendingFollowUp: false,
});
}
}
}
@@ -4026,6 +4138,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerSubAgentToolCall({
timelineItem: next.route.toolCall,
rawItem: { agentThreadId: next.route.childThreadId },
childThreadIds: [next.route.childThreadId],
parentCallId: next.parentCallId,
parentSubagentId: next.parentSubagentId,
});
@@ -5673,6 +5786,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private registerSubAgentToolCall(params: {
timelineItem: ToolCallTimelineItem;
rawItem: { [key: string]: unknown };
childThreadIds: readonly string[];
parentCallId: string | null;
parentSubagentId: string | null;
}): string[] {
@@ -5714,16 +5828,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.subAgentCallsByCallId.set(timelineItem.callId, state);
const receiverThreadIds = Array.isArray(rawItem.receiverThreadIds)
? rawItem.receiverThreadIds.filter((value): value is string => typeof value === "string")
: [];
const agentThreadId =
typeof rawItem.agentThreadId === "string" && rawItem.agentThreadId.length > 0
? rawItem.agentThreadId
: null;
const childThreadIds = Array.from(
new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds),
).filter((threadId) => threadId !== this.currentThreadId);
const childThreadIds = Array.from(new Set(params.childThreadIds)).filter(
(threadId) => threadId !== this.currentThreadId,
);
for (const receiverThreadId of childThreadIds) {
const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId);
if (owningCallId && owningCallId !== timelineItem.callId) {
@@ -5736,7 +5843,36 @@ export class CodexAppServerAgentSession implements AgentSession {
return childThreadIds;
}
private handleRegisteredCollabAgentControl(
rawItem: { [key: string]: unknown },
control: CodexCollabAgentControl,
): boolean {
if (control.kind === "submission") return false;
for (const update of control.updates) {
const callId = this.subAgentCallIdByChildThreadId.get(update.threadId);
if (!callId) continue;
const state = this.subAgentCallsByCallId.get(callId);
if (!state) continue;
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
if (status !== state.toolCall.status) {
this.emitSubAgentActivityUpdate(callId, status);
}
}
// A control rendered at start must also render its completion, even if
// Codex's final result lists fewer receivers than its started record.
const hasOwnCard = typeof rawItem.id === "string" && this.subAgentCallsByCallId.has(rawItem.id);
return (
!hasOwnCard &&
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id))
);
}
private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean {
const control = readCodexCollabAgentControl(rawItem);
if (control) {
return this.handleRegisteredCollabAgentControl(rawItem, control);
}
const activity = readCodexSubAgentActivity(rawItem);
if (!activity) {
return false;
@@ -6630,6 +6766,7 @@ export class CodexAppServerAgentSession implements AgentSession {
? this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
@@ -6797,6 +6934,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const registeredChildThreadIds = this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
});
@@ -0,0 +1,463 @@
import { describe, expect, test, vi } from "vitest";
import { CodexAppServerAgentSession } from "../codex-app-server-agent.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createFakeCodexAppServer } from "./test-utils/fake-app-server.js";
function collab(tool: string, states: Record<string, string>, id = tool) {
return {
type: "collabAgentToolCall",
id,
tool,
status: "completed",
prompt: tool === "spawnAgent" ? "Reply with a short result." : null,
receiverThreadIds: Object.keys(states),
agentsStates: Object.fromEntries(
Object.entries(states).map(([threadId, status]) => [threadId, { status }]),
),
};
}
async function replay(items: unknown[]) {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => ({
thread: {
turns: [
{
items: (params as { threadId: string }).threadId === "parent" ? items : [],
},
],
},
}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
{ sessionId: "parent" },
createTestLogger(),
async () => appServer.child,
{},
false,
false,
false,
undefined,
"history",
);
try {
await session.connect();
return await Array.fromAsync(session.streamHistory());
} finally {
await session.close();
appServer.assertNoErrors();
}
}
describe("Codex collaboration history", () => {
test("restores completed children from nullable wait and close results", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
collab("wait", { first: "completed" }, "wait-first"),
collab("wait", { second: "completed" }, "wait-second"),
collab("closeAgent", { first: "completed" }, "close-first"),
collab("closeAgent", { second: "completed" }, "close-second"),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: "completed" },
{ id: "second", status: "completed" },
]);
});
test.each(["completed", "interrupted", "running"])(
"keeps follow-up instructions in replay without applying the %s submission snapshot",
async (submissionStatus) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
{ ...collab("sendInput", { first: submissionStatus }), prompt: "Now add tests" },
]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status, detail: event.item.detail }]
: [],
),
).toEqual([
{ id: "spawn-first", status: "completed", detail: expect.anything() },
{
id: "sendInput",
status: "completed",
detail: expect.objectContaining({ description: "Now add tests" }),
},
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "completed" }]);
},
);
test.each([
["completed", "closeAgent", "errored", "failed"],
["errored", "closeAgent", "completed", "completed"],
["completed", "wait", "running", "running"],
["completed", "closeAgent", "running", "canceled"],
])(
"replaces the earlier %s outcome after a follow-up and %s result %s",
async (initialStatus, tool, resultStatus, expected) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
{
...collab("wait", { first: initialStatus, second: "completed" }),
status: initialStatus === "errored" ? "failed" : "completed",
},
{ ...collab("sendInput", { first: "running" }), prompt: "Do more work" },
{
...collab(tool, { first: resultStatus }),
status: resultStatus === "errored" ? "failed" : "completed",
},
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: expected },
{ id: "second", status: "completed" },
]);
expect(
history.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "spawn-first"
? [event.item.status]
: [],
),
).toEqual([expected]);
},
);
test("keeps the prior outcome when a follow-up submission fails", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
{ ...collab("sendInput", { first: "running" }), status: "failed", prompt: "Do more work" },
collab("wait", { first: "running" }, "stale-wait"),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
),
).toEqual(["completed"]);
});
test("settles a close card without a registered spawn", async () => {
const history = await replay([collab("closeAgent", { external: "running" })]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status }]
: [],
),
).toEqual([{ id: "closeAgent", status: "canceled" }]);
});
test.each(["notFound", "errored"])(
"preserves completion and shows a failed resume reporting %s",
async (status) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
collab("closeAgent", { first: "completed" }),
{ ...collab("resumeAgent", { first: status }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
),
).toEqual(["completed"]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status }]
: [],
),
).toEqual([
{ id: "spawn-first", status: "completed" },
{ id: "resumeAgent", status: "failed" },
]);
},
);
test("stops counting a child closed while running", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("closeAgent", { first: "running" }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "canceled" }]);
});
test.each(["wait", "closeAgent"])(
"restores an errored child as failed after %s",
async (tool) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
{ ...collab(tool, { first: "errored" }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "failed" }]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status, error: event.item.error }]
: [],
),
).toEqual([{ id: "spawn-first", status: "failed", error: { message: "Sub-agent failed" } }]);
},
);
test.each(["wait", "closeAgent"])(
"settles a live errored child as failed after %s",
async (tool) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const statuses = new Map<string, string>();
const cards = new Map<string, { status: string; error: unknown }>();
session.subscribe((event) => {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
if (event.type === "timeline" && event.item.type === "tool_call") {
cards.set(event.item.callId, { status: event.item.status, error: event.item.error });
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab(tool, { first: "errored" }),
status: "inProgress",
agentsStates: {},
});
notify("item/completed", { ...collab(tool, { first: "errored" }), status: "failed" });
appServer.completeTurn();
await result;
expect([...statuses]).toEqual([["first", "failed"]]);
expect([...cards]).toEqual([
["spawn-first", { status: "failed", error: { message: "Sub-agent failed" } }],
]);
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
test("preserves completion when a close carries an older running snapshot", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
collab("closeAgent", { first: "running" }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "completed" }]);
});
test.each([
["shutdown", "canceled"],
["notFound", "failed"],
])("restores a %s child as %s and preserves its running sibling", async (status, expected) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
collab("closeAgent", { first: status }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: expected },
{ id: "second", status: "running" },
]);
});
test("settles a rendered live wait when its completion lists only the finished receiver", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const cards = new Map<string, string>();
const statuses = new Map<string, string>();
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "tool_call") {
cards.set(event.item.callId, event.item.status);
}
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Wait for the children.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab("wait", { first: "running", second: "running" }, "mixed-wait"),
status: "inProgress",
agentsStates: {},
});
await vi.waitFor(() => expect(cards.get("mixed-wait")).toBe("running"));
notify("item/completed", collab("wait", { first: "completed" }, "mixed-wait"));
notify("item/completed", collab("wait", { second: "completed" }, "wait-second"));
appServer.completeTurn();
await result;
expect([...statuses]).toEqual([["first", "completed"]]);
expect([...cards]).toEqual([
["spawn-first", "completed"],
["mixed-wait", "completed"],
["wait-second", "completed"],
]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test.each(["completed", "interrupted", "running"])(
"keeps a live follow-up running despite a late %s sendInput snapshot",
async (submissionStatus) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const statuses = new Map<string, string>();
const timelineCards = new Map<string, { status: string; detail: unknown }>();
let parentMessage = "";
session.subscribe((event) => {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineCards.set(event.item.callId, {
status: event.item.status,
detail: event.item.detail,
});
}
if (event.type === "timeline" && event.item.type === "assistant_message") {
parentMessage = event.item.text;
}
});
function complete(tool: string, states: Record<string, string>, id = tool) {
const item = {
...collab(tool, states, id),
...(tool === "sendInput" ? { prompt: "Now add tests" } : {}),
};
if (tool !== "spawnAgent") {
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: "thread-1",
item: { ...item, status: "inProgress", agentsStates: {} },
},
})}\n`,
);
}
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", item },
})}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
complete("spawnAgent", { first: "pendingInit" }, "spawn-first");
complete("spawnAgent", { second: "pendingInit" }, "spawn-second");
complete("wait", { first: "completed", second: "running" });
await vi.waitFor(() => {
expect(statuses.get("first")).toBe("completed");
expect(statuses.get("second")).toBe("running");
});
complete("closeAgent", { first: "shutdown" });
complete("wait", { first: "running" }, "stale-wait");
appServer.says({ threadId: "thread-1", text: "Still waiting." });
appServer.completeTurn();
await result;
expect(statuses.get("first")).toBe("completed");
appServer.startsTurn({ threadId: "first", turnId: "follow-up" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("running"));
complete("sendInput", { first: submissionStatus });
appServer.says({ threadId: "thread-1", text: "Follow-up submitted." });
await vi.waitFor(() => expect(parentMessage).toBe("Follow-up submitted."));
expect(statuses.get("first")).toBe("running");
expect([...timelineCards.keys()]).toEqual(["spawn-first", "spawn-second", "sendInput"]);
expect(timelineCards.get("sendInput")).toEqual({
status: "completed",
detail: expect.objectContaining({ description: "Now add tests" }),
});
// Only the child's completion settles the follow-up.
appServer.completeTurn({ threadId: "first" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("completed"));
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
});
@@ -213,6 +213,20 @@ describe("codex tool-call mapper", () => {
});
});
it("accepts a nullable prompt in completed collaboration control items", () => {
expect(
mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "wait-child",
tool: "wait",
prompt: null,
status: "completed",
receiverThreadIds: ["child"],
agentsStates: { child: { status: "completed" } },
}),
).toMatchObject({ callId: "wait-child", status: "completed" });
});
it("maps collabAgentToolCall into canonical sub-agent detail", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
@@ -186,7 +186,7 @@ const CodexCollabAgentToolCallItemSchema = z
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
prompt: z.string().optional(),
prompt: z.string().nullish(),
tool: z.string().optional(),
receiverThreadIds: z.array(z.string()).optional(),
agentsStates: z.record(z.string(), z.unknown()).optional(),
@@ -545,12 +545,18 @@ function readStatus(value: unknown): string | undefined {
return typeof value.status === "string" ? value.status : undefined;
}
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
export function normalizeCollabAgentChildStatus(
status: string,
tool?: string,
): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
switch (normalized) {
case "error":
case "errored":
return "running";
return tool === "wait" || tool === "closeAgent" ? "failed" : "running";
case "running":
case "pendinginit":
return tool === "closeAgent" ? "canceled" : "running";
case "shutdown":
return "canceled";
case "notfound":
@@ -572,10 +578,15 @@ function resolveCollabAgentStatus(
return "failed";
}
// Sending input completes the submission, not the child's new turn.
if (item.tool === "sendInput") {
return parentStatus;
}
const childStatuses = Object.values(item.agentsStates ?? {})
.map(readStatus)
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
.map(normalizeCollabAgentChildStatus);
.map((status) => normalizeCollabAgentChildStatus(status, item.tool));
if (childStatuses.some((status) => status === "failed")) {
return "failed";
@@ -667,33 +667,6 @@ test("starting and stopping a session-hook plugin each causes one idle reopen",
}
}, 60_000);
test("an unchanged session-open hook does not reopen an idle provider session", async () => {
const source = (value: string) => `
export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => {
return { ...request, env: { ...request.env, STABLE: ${JSON.stringify(value)} } };
});
return () => {};
}
`;
const h = await setup(false, source("1"));
try {
const agent = await h.create();
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("1");
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after the same hook");
expect(h.launches).toHaveLength(1);
await writeFile(path.join(h.directory, "tools-test", "index.server.ts"), source("2"));
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after the hook changes");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
expect(h.events.interrupts).toBe(0);
} finally {
await h.close();
}
}, 60_000);
test("plugins without a session-open hook do not make agent sessions stale", async () => {
const h = await setup(true);
try {
@@ -885,7 +858,7 @@ test("installing and disabling an MCP plugin reopens idle sessions before their
}
}, 60_000);
test("installing and disabling an MCP plugin during a turn leaves the provider session running", async () => {
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
@@ -912,7 +885,7 @@ test("installing and disabling an MCP plugin during a turn leaves the provider s
release();
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
await sendAndFinish(h, a.id, "idle");
expect(h.launches).toHaveLength(1);
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
} finally {
release();
@@ -1067,57 +1040,3 @@ test("session-open MCP configs are validated before a provider launches", async
await h.close();
}
}, 60_000);
test("diagnostic: unchanged observer reload keeps a slow-closing provider usable", async () => {
let closes = 0;
const h = await setup(
false,
`
export default function contribute(server) {
return server.addHook("agent.open.before", async () => {});
}
`,
undefined,
{
closeSession: async () => {
closes++;
await new Promise((resolve) => setTimeout(resolve, 3700));
},
},
);
try {
const agent = await h.create();
await sendAndFinish(h, agent.id, "first");
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after reload");
expect(closes).toBe(0);
expect(h.launches).toHaveLength(1);
} finally {
await h.close();
}
}, 60_000);
test("diagnostic: a changed session-open hook runs once for the replacement process", async () => {
const source = (value: string) => `
let calls = 0;
export default function contribute(server) {
return server.addHook("agent.open.before", async ({ request }) => ({
...request,
env: { ...request.env, STABLE: ${JSON.stringify(value)}, HOOK_CALL: String(++calls) },
}));
}
`;
const h = await setup(false, source("1"));
try {
const agent = await h.create();
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("1");
await writeFile(path.join(h.directory, "tools-test", "index.server.ts"), source("2"));
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after changed hook");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("1");
} finally {
await h.close();
}
}, 60_000);
@@ -581,6 +581,9 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
let fsCalls = 0;
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
await tick();
// Do not charge garbage left by repository setup to the event filter.
// Allocations and GC caused by the measured callback remain in its budget.
global.gc();
const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => {
await marker('FILTER_START');
hook.enable();
@@ -614,6 +617,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
[
"--expose-gc",
"--import",
"tsx",
...(tracing ? ["--trace-sync-io"] : []),