mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8fcc17e4 | ||
|
|
e1e48d38fd | ||
|
|
a8e0effb32 | ||
|
|
61ac3aa6c0 | ||
|
|
6a0e53f6dc |
@@ -11,6 +11,10 @@ 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.
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
- 新增采用 `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 服务器。
|
||||
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
orientation: "default",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
|
||||
@@ -59,3 +59,38 @@ 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,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
@@ -22,14 +23,7 @@ import {
|
||||
type ComponentType,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { 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,5 +1,6 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Modal, View } from "react-native";
|
||||
import { 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,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,7 +13,6 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
@@ -16,7 +17,6 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
type PressableProps,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -102,6 +103,32 @@ 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 = {
|
||||
@@ -780,6 +807,7 @@ 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;
|
||||
@@ -913,11 +941,49 @@ 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 (
|
||||
@@ -957,6 +1023,7 @@ export class AgentManager {
|
||||
private detachPluginTools(agentId: string): void {
|
||||
this.attachedPluginTools.delete(agentId);
|
||||
this.sessionOpenGenerations.delete(agentId);
|
||||
this.sessionOpenContributions.delete(agentId);
|
||||
this.pluginAgentTools?.detachTools(agentId);
|
||||
}
|
||||
|
||||
@@ -5566,6 +5633,13 @@ 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,
|
||||
@@ -5573,7 +5647,7 @@ export class AgentManager {
|
||||
cwd,
|
||||
workspaceId: opening?.workspaceId ?? null,
|
||||
reason: opening?.reason ?? "resume",
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
purpose,
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
@@ -5582,11 +5656,18 @@ 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, transformed.tools as AttachedAgentTool[]);
|
||||
this.attachedPluginTools.set(agentId, tools);
|
||||
mcpServers = transformed.mcpServers;
|
||||
mcpServerOwners = transformed.mcpServerOwners;
|
||||
}
|
||||
contribution = sessionOpenContribution({
|
||||
purpose: request.purpose,
|
||||
env,
|
||||
tools,
|
||||
mcpServers: transformed.mcpServers,
|
||||
});
|
||||
}
|
||||
const context: AgentLaunchContext = {
|
||||
agentId,
|
||||
@@ -5618,6 +5699,7 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
this.sessionOpenGenerations.set(agentId, generation);
|
||||
this.sessionOpenContributions.set(agentId, contribution);
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
mapCodexToolCallEnvelope,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
normalizeCommandExecutionCommand,
|
||||
normalizeCollabAgentChildStatus,
|
||||
splitCodexMcpToolResultImages,
|
||||
} from "./codex/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -1858,77 +1857,6 @@ 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);
|
||||
}
|
||||
@@ -1969,7 +1897,7 @@ function updateHistoricalSubAgentActivity(
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexSpawnedSubAgentThreadIds(item: unknown): string[] {
|
||||
function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] {
|
||||
const activity = readCodexSubAgentActivity(item);
|
||||
if (activity) {
|
||||
return activity.kind === "started" ? [activity.agentThreadId] : [];
|
||||
@@ -2132,57 +2060,23 @@ 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 subAgentsByThreadId = new Map<string, HistoricalCodexSubAgent>();
|
||||
const subAgentTimelineIndexByThreadId = new Map<string, number>();
|
||||
try {
|
||||
await projectCodexThreadHistory(params, { timeline, subAgentsByThreadId });
|
||||
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
|
||||
} 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(subAgentsByThreadId.entries()).flatMap(
|
||||
([childThreadId, child]): PersistedSubAgentRoute[] => {
|
||||
const item = timeline[child.timelineIndex]?.item;
|
||||
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
|
||||
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
|
||||
const item = timeline[timelineIndex]?.item;
|
||||
return item?.type === "tool_call" && item.detail.type === "sub_agent"
|
||||
? [{ childThreadId, toolCall: item }]
|
||||
: [];
|
||||
@@ -2195,20 +2089,17 @@ async function projectCodexThreadHistory(
|
||||
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
|
||||
projection: {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
subAgentsByThreadId: Map<string, HistoricalCodexSubAgent>;
|
||||
subAgentTimelineIndexByThreadId: Map<string, number>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { timeline, subAgentsByThreadId } = projection;
|
||||
const { timeline, subAgentTimelineIndexByThreadId } = 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 = subAgentsByThreadId.get(
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
)?.timelineIndex;
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
@@ -2238,11 +2129,8 @@ async function projectCodexThreadHistory(
|
||||
? { providerTurnId: row.turnId }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexSpawnedSubAgentThreadIds(item)) {
|
||||
subAgentsByThreadId.set(childThreadId, {
|
||||
timelineIndex: timeline.length - 1,
|
||||
pendingFollowUp: false,
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4138,7 +4026,6 @@ 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,
|
||||
});
|
||||
@@ -5786,7 +5673,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private registerSubAgentToolCall(params: {
|
||||
timelineItem: ToolCallTimelineItem;
|
||||
rawItem: { [key: string]: unknown };
|
||||
childThreadIds: readonly string[];
|
||||
parentCallId: string | null;
|
||||
parentSubagentId: string | null;
|
||||
}): string[] {
|
||||
@@ -5828,9 +5714,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.subAgentCallsByCallId.set(timelineItem.callId, state);
|
||||
|
||||
const childThreadIds = Array.from(new Set(params.childThreadIds)).filter(
|
||||
(threadId) => threadId !== this.currentThreadId,
|
||||
);
|
||||
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);
|
||||
for (const receiverThreadId of childThreadIds) {
|
||||
const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId);
|
||||
if (owningCallId && owningCallId !== timelineItem.callId) {
|
||||
@@ -5843,36 +5736,7 @@ 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;
|
||||
@@ -6766,7 +6630,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
? this.registerSubAgentToolCall({
|
||||
timelineItem,
|
||||
rawItem: parsed.item,
|
||||
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
|
||||
parentCallId: childSubAgentCallId,
|
||||
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
|
||||
})
|
||||
@@ -6934,7 +6797,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const registeredChildThreadIds = this.registerSubAgentToolCall({
|
||||
timelineItem,
|
||||
rawItem: parsed.item,
|
||||
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
|
||||
parentCallId: childSubAgentCallId,
|
||||
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
|
||||
});
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
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,20 +213,6 @@ 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().nullish(),
|
||||
prompt: z.string().optional(),
|
||||
tool: z.string().optional(),
|
||||
receiverThreadIds: z.array(z.string()).optional(),
|
||||
agentsStates: z.record(z.string(), z.unknown()).optional(),
|
||||
@@ -545,18 +545,12 @@ function readStatus(value: unknown): string | undefined {
|
||||
return typeof value.status === "string" ? value.status : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCollabAgentChildStatus(
|
||||
status: string,
|
||||
tool?: string,
|
||||
): ToolCallTimelineItem["status"] {
|
||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "error":
|
||||
case "errored":
|
||||
return tool === "wait" || tool === "closeAgent" ? "failed" : "running";
|
||||
case "running":
|
||||
case "pendinginit":
|
||||
return tool === "closeAgent" ? "canceled" : "running";
|
||||
return "running";
|
||||
case "shutdown":
|
||||
return "canceled";
|
||||
case "notfound":
|
||||
@@ -578,15 +572,10 @@ 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((status) => normalizeCollabAgentChildStatus(status, item.tool));
|
||||
.map(normalizeCollabAgentChildStatus);
|
||||
|
||||
if (childStatuses.some((status) => status === "failed")) {
|
||||
return "failed";
|
||||
|
||||
@@ -667,6 +667,33 @@ 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 {
|
||||
@@ -858,7 +885,7 @@ test("installing and disabling an MCP plugin reopens idle sessions before their
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
|
||||
test("installing and disabling an MCP plugin during a turn leaves the provider session running", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
@@ -885,7 +912,7 @@ test("installing and disabling an MCP plugin never interrupts a running turn", a
|
||||
release();
|
||||
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
|
||||
await sendAndFinish(h, a.id, "idle");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
} finally {
|
||||
release();
|
||||
@@ -1040,3 +1067,57 @@ 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,9 +581,6 @@ 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();
|
||||
@@ -617,7 +614,6 @@ 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"] : []),
|
||||
|
||||
Reference in New Issue
Block a user