mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5f98b3a4 |
@@ -358,14 +358,6 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
|
||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||
Electron running the old code.
|
||||
|
||||
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
|
||||
owned by Unistyles' change listeners. Chromium defers their events while a window
|
||||
is hidden; reading `matches` on those same lists can consume the pending change.
|
||||
CSS variables then follow the OS theme while `withUnistyles` props retain the old
|
||||
colors. Keep this patch until upstream reads the scheme through separate lists.
|
||||
The desktop browser E2E checks both transition directions in an isolated Electron
|
||||
process, because Playwright's focus emulation prevents real hidden-page behavior.
|
||||
|
||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||
`patches/` is inert unless that script knows which installed package activates
|
||||
it.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -64,7 +64,7 @@ function resolveShouldFill(
|
||||
): boolean {
|
||||
if (!fillAvailableHeight) return false;
|
||||
const t = detail?.type;
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read";
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
|
||||
}
|
||||
|
||||
function useDetailStyles(
|
||||
@@ -186,6 +186,184 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSubAgentFallbackHeader(
|
||||
subAgentType: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (subAgentType && description) {
|
||||
return `${subAgentType}: ${description}`;
|
||||
}
|
||||
return subAgentType ?? description ?? fallbackText;
|
||||
}
|
||||
|
||||
interface SubAgentDetailProps {
|
||||
log: string;
|
||||
childSessionId: string | null | undefined;
|
||||
subAgentType: string | null | undefined;
|
||||
description: string | null | undefined;
|
||||
ds: DetailStyles;
|
||||
}
|
||||
|
||||
interface SubAgentActivityRow {
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface ParsedSubAgentLog {
|
||||
actions: SubAgentActivityRow[];
|
||||
remainingLog: string;
|
||||
}
|
||||
|
||||
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
|
||||
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const toolName = match[1]?.trim();
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const summary = match[2]?.trim();
|
||||
return {
|
||||
index,
|
||||
toolName,
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubAgentLog(log: string): ParsedSubAgentLog {
|
||||
const actions: SubAgentActivityRow[] = [];
|
||||
const remainingLines: string[] = [];
|
||||
for (const line of log.replace(/^\n+/, "").split("\n")) {
|
||||
const normalizedLine = line.trim();
|
||||
if (!normalizedLine) {
|
||||
continue;
|
||||
}
|
||||
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
|
||||
if (parsedAction) {
|
||||
actions.push(parsedAction);
|
||||
} else {
|
||||
remainingLines.push(line);
|
||||
}
|
||||
}
|
||||
return {
|
||||
actions,
|
||||
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
|
||||
return (
|
||||
<View style={styles.subAgentActionRow}>
|
||||
<Text selectable style={styles.subAgentActionTool}>
|
||||
{formatSubAgentToolName(action.toolName)}
|
||||
</Text>
|
||||
{action.summary ? (
|
||||
<Text selectable style={styles.subAgentActionSummary}>
|
||||
{action.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSubAgentToolName(toolName: string): string {
|
||||
const trimmed = toolName.trim();
|
||||
if (!trimmed) {
|
||||
return toolName;
|
||||
}
|
||||
return trimmed
|
||||
.replace(/[._-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function SubAgentLogText({
|
||||
activityLog,
|
||||
fallbackHeader,
|
||||
hasActions,
|
||||
}: {
|
||||
activityLog: string;
|
||||
fallbackHeader: string;
|
||||
hasActions: boolean;
|
||||
}) {
|
||||
if (activityLog.length > 0) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{activityLog}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (!hasActions) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{fallbackHeader}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SubAgentDetailSection({
|
||||
log,
|
||||
childSessionId,
|
||||
subAgentType,
|
||||
description,
|
||||
ds,
|
||||
}: SubAgentDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
|
||||
const fallbackHeader = resolveSubAgentFallbackHeader(
|
||||
subAgentType,
|
||||
description,
|
||||
t("toolCallDetails.subAgentActivity"),
|
||||
);
|
||||
const hasActions = actions.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<ScrollView
|
||||
style={ds.codeVerticalScrollStyle}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
{childSessionId ? (
|
||||
<Text selectable style={styles.subAgentSessionText}>
|
||||
session {childSessionId}
|
||||
</Text>
|
||||
) : null}
|
||||
{hasActions ? (
|
||||
<View style={styles.subAgentActions}>
|
||||
{actions.map((action) => (
|
||||
<SubAgentActionRow key={action.index} action={action} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<SubAgentLogText
|
||||
activityLog={remainingLog}
|
||||
fallbackHeader={fallbackHeader}
|
||||
hasActions={hasActions}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditDetailProps {
|
||||
diffLines: DiffLine[] | undefined;
|
||||
ds: DetailStyles;
|
||||
@@ -458,7 +636,19 @@ function buildDetailSections(
|
||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
|
||||
if (detail.type === "worktree_setup") return [];
|
||||
if (detail.type === "sub_agent") {
|
||||
return [
|
||||
<SubAgentDetailSection
|
||||
key="sub-agent"
|
||||
log={detail.log}
|
||||
childSessionId={detail.childSessionId}
|
||||
subAgentType={detail.subAgentType}
|
||||
description={detail.description}
|
||||
ds={ds}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "edit") {
|
||||
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
||||
}
|
||||
@@ -710,6 +900,34 @@ const styles = StyleSheet.create((theme) => {
|
||||
shellPrompt: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
subAgentSessionText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActions: {
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
subAgentActionTool: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
},
|
||||
subAgentActionSummary: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
jsonScroll: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -832,6 +832,7 @@ describe("translation resources", () => {
|
||||
expect(en.common.placeholders.search).toBe("Search...");
|
||||
expect(en.common.empty.noResults).toBe("No results found");
|
||||
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
|
||||
expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
|
||||
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
|
||||
});
|
||||
|
||||
|
||||
@@ -2013,6 +2013,7 @@ export const ar: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "خطأ",
|
||||
empty: "لا توجد تفاصيل إضافية متاحة",
|
||||
subAgentActivity: "نشاط الوكيل الفرعي",
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
|
||||
@@ -2038,6 +2038,7 @@ export const en = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No additional details available",
|
||||
subAgentActivity: "Sub-agent activity",
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
|
||||
@@ -2061,6 +2061,7 @@ export const es: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No hay detalles adicionales disponibles",
|
||||
subAgentActivity: "Actividad de subagente",
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
|
||||
@@ -2060,6 +2060,7 @@ export const fr: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erreur",
|
||||
empty: "Aucun détail supplémentaire disponible",
|
||||
subAgentActivity: "Activité du sous-agent",
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
|
||||
@@ -2033,6 +2033,7 @@ export const ja: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "エラー",
|
||||
empty: "追加の詳細はありません",
|
||||
subAgentActivity: "サブエージェントアクティビティ",
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
|
||||
@@ -2023,6 +2023,7 @@ export const ko: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "오류",
|
||||
empty: "추가 세부 정보가 없습니다",
|
||||
subAgentActivity: "서브에이전트 활동",
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
|
||||
@@ -2046,6 +2046,7 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erro",
|
||||
empty: "Nenhum detalhe adicional disponível",
|
||||
subAgentActivity: "Atividade do subagente",
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
|
||||
@@ -2046,6 +2046,7 @@ export const ru: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Ошибка",
|
||||
empty: "Дополнительные сведения отсутствуют",
|
||||
subAgentActivity: "Активность субагента",
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
|
||||
@@ -1991,6 +1991,7 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "错误",
|
||||
empty: "没有可用的更多详情",
|
||||
subAgentActivity: "Sub-agent 活动",
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
|
||||
@@ -40,6 +40,12 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
|
||||
return Boolean(detail.url || detail.result || detail.codeText);
|
||||
}
|
||||
|
||||
function hasMeaningfulSubAgentDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
|
||||
): boolean {
|
||||
return Boolean(detail.subAgentType || detail.description || detail.log);
|
||||
}
|
||||
|
||||
function hasMeaningfulPlainTextDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
||||
): boolean {
|
||||
@@ -69,9 +75,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
||||
case "fetch":
|
||||
return hasMeaningfulFetchDetail(detail);
|
||||
case "worktree_setup":
|
||||
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
|
||||
case "sub_agent":
|
||||
return false;
|
||||
case "sub_agent":
|
||||
return hasMeaningfulSubAgentDetail(detail);
|
||||
case "plain_text":
|
||||
return hasMeaningfulPlainTextDetail(detail);
|
||||
case "plan":
|
||||
@@ -86,7 +92,7 @@ export function isPendingToolCallDetail(params: {
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
}): boolean {
|
||||
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
|
||||
if (params.detail?.type === "worktree_setup") return false;
|
||||
const isRunning = params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import electronPath from "electron";
|
||||
import { build } from "esbuild";
|
||||
|
||||
export async function runAdaptiveThemeRegression(artifactDir) {
|
||||
const { outputFiles } = await build({
|
||||
stdin: {
|
||||
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
|
||||
contents: `
|
||||
import { createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||
StyleSheet.configure({
|
||||
themes: {
|
||||
dark: { foreground: "#fafafa", background: "#181b1a" },
|
||||
light: { foreground: "#1a1a1e", background: "#ffffff" },
|
||||
},
|
||||
settings: { adaptiveThemes: true },
|
||||
});
|
||||
const Message = withUnistyles(({ color }) =>
|
||||
createElement("p", { style: { color } }, "Theme-reactive message"));
|
||||
createRoot(document.getElementById("root")).render(createElement(Message, {
|
||||
style: { margin: 0 },
|
||||
uniProps: theme => ({ color: theme.foreground }),
|
||||
}));
|
||||
window.readThemeForTest = () => UnistylesRuntime.themeName;
|
||||
`,
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
alias: { "react-native": "react-native-web" },
|
||||
define: { __DEV__: "false" },
|
||||
resolveExtensions: [".web.js", ".js", ".json"],
|
||||
});
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
|
||||
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
|
||||
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
const args = [
|
||||
...(process.platform === "linux" ? ["--no-sandbox"] : []),
|
||||
path.join(directory, "main.cjs"),
|
||||
artifactDir,
|
||||
];
|
||||
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
|
||||
const commandArgs =
|
||||
process.platform === "linux"
|
||||
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
|
||||
: args;
|
||||
await promisify(execFile)(command, commandArgs, {
|
||||
env,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function electronRegression() {
|
||||
const { app, BrowserWindow, nativeTheme } = require("electron");
|
||||
const assert = require("node:assert/strict");
|
||||
const files = require("node:fs");
|
||||
const paths = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
app.setPath("userData", paths.join(__dirname, "profile"));
|
||||
app.on("window-all-closed", () => {});
|
||||
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
|
||||
const artifactDir = process.argv.at(-1);
|
||||
let window;
|
||||
const observations = [];
|
||||
const colors = {
|
||||
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
|
||||
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
|
||||
};
|
||||
async function read(expression) {
|
||||
return window.webContents.executeJavaScript(expression);
|
||||
}
|
||||
async function expectValue(expression, expected) {
|
||||
let actual;
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
actual = await read(expression);
|
||||
if (actual === expected) return;
|
||||
await delay(50);
|
||||
}
|
||||
assert.equal(actual, expected, expression);
|
||||
}
|
||||
try {
|
||||
await app.whenReady();
|
||||
if (process.platform === "darwin") {
|
||||
app.setActivationPolicy("accessory");
|
||||
app.dock.hide();
|
||||
}
|
||||
for (const [from, to] of [
|
||||
["dark", "light"],
|
||||
["light", "dark"],
|
||||
]) {
|
||||
nativeTheme.themeSource = from;
|
||||
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
|
||||
await window.loadURL(
|
||||
"data:text/html," +
|
||||
encodeURIComponent(`<!doctype html>
|
||||
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
|
||||
<div id="root"></div>`),
|
||||
);
|
||||
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
|
||||
window.showInactive();
|
||||
const foreground =
|
||||
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
|
||||
const background = "getComputedStyle(document.body).backgroundColor";
|
||||
await expectValue(foreground, colors[from].foreground);
|
||||
await expectValue(background, colors[from].background);
|
||||
window.hide();
|
||||
await expectValue("document.visibilityState", "hidden");
|
||||
nativeTheme.themeSource = to;
|
||||
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
|
||||
// Reading the public runtime while hidden must not consume the pending theme notification.
|
||||
assert.equal(await read("window.readThemeForTest()"), to);
|
||||
window.showInactive();
|
||||
await expectValue("document.visibilityState", "visible");
|
||||
await expectValue(background, colors[to].background);
|
||||
try {
|
||||
await expectValue(foreground, colors[to].foreground);
|
||||
} finally {
|
||||
files.writeFileSync(
|
||||
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
|
||||
(await window.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
}
|
||||
observations.push({
|
||||
from,
|
||||
to,
|
||||
foreground: await read(foreground),
|
||||
background: await read(background),
|
||||
});
|
||||
window.destroy();
|
||||
window = undefined;
|
||||
}
|
||||
files.writeFileSync(
|
||||
paths.join(artifactDir, "adaptive-theme.json"),
|
||||
JSON.stringify(observations, null, 2),
|
||||
);
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
window?.destroy();
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
||||
import { chromium } from "playwright";
|
||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||
|
||||
@@ -1242,7 +1241,6 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
|
||||
await runAppearanceFontSizeRegression(page);
|
||||
|
||||
const callerAgentId = await createCallerAgent(daemonPort);
|
||||
@@ -1267,12 +1265,7 @@ async function main() {
|
||||
artifactDir,
|
||||
});
|
||||
const pluginLinks = await checkPluginLinks();
|
||||
writeJson(path.join(artifactDir, "result.json"), {
|
||||
...report,
|
||||
settingsMemory,
|
||||
pluginLinks,
|
||||
adaptiveTheme,
|
||||
});
|
||||
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
|
||||
console.log(
|
||||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
||||
);
|
||||
|
||||
@@ -288,7 +288,7 @@ export type ToolCallDetail =
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
log?: string;
|
||||
log: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
|
||||
@@ -638,9 +638,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
|
||||
subAgentType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
childSessionId: z.string().optional(),
|
||||
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a
|
||||
// launch marker; apps before v0.14.1 required `log`, so daemons still send it empty.
|
||||
log: z.string().optional(),
|
||||
log: z.string(),
|
||||
// Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still
|
||||
// emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those
|
||||
// clients are no longer in the field.
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const card = {
|
||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-1",
|
||||
name: "Task",
|
||||
@@ -212,26 +212,12 @@ describe("wire schema compatibility", () => {
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
childSessionId: "child-session-1",
|
||||
log: "",
|
||||
log: "[Read] README.md",
|
||||
actions: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
|
||||
expect(parsed.detail).toMatchObject({ log: "", actions: [] });
|
||||
const { log: _log, ...withoutLog } = card.detail;
|
||||
expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
|
||||
detail: { type: "sub_agent", description: "Inspect repository structure" },
|
||||
});
|
||||
expect(
|
||||
AgentTimelineItemPayloadSchema.parse({
|
||||
...card,
|
||||
detail: { ...card.detail, log: "[Read] README.md" },
|
||||
}),
|
||||
).toMatchObject({ detail: { log: "[Read] README.md" } });
|
||||
|
||||
expect(parsed.detail.actions).toEqual([]);
|
||||
});
|
||||
|
||||
test("old clients parse agent snapshots with rewind capabilities", () => {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// Opt-in diagnostic for #3905. Run from the repository root:
|
||||
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
|
||||
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
|
||||
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
|
||||
|
||||
const megabytes = Number(process.argv[2] ?? "520");
|
||||
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
|
||||
throw new Error("Expected an integer size between 1 and 600 MiB");
|
||||
}
|
||||
const historyMode = process.argv[3] ?? "legacy";
|
||||
if (historyMode !== "legacy" && historyMode !== "paginated") {
|
||||
throw new Error("Expected history mode legacy or paginated");
|
||||
}
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
|
||||
const home = path.join(root, "codex-home");
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const threadId = randomUUID();
|
||||
const turnId = randomUUID();
|
||||
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
|
||||
await mkdir(sessions, { recursive: true });
|
||||
const rollout = await open(
|
||||
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
|
||||
"w",
|
||||
);
|
||||
async function write(type: string, payload: unknown) {
|
||||
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
|
||||
}
|
||||
await write("session_meta", {
|
||||
id: threadId,
|
||||
timestamp,
|
||||
cwd: root,
|
||||
originator: "codex_cli_rs",
|
||||
cli_version: "0.159.0",
|
||||
source: "cli",
|
||||
model_provider: "openai",
|
||||
history_mode: "legacy",
|
||||
});
|
||||
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
|
||||
await write("event_msg", {
|
||||
type: "user_message",
|
||||
message: "Synthetic oversized history",
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
});
|
||||
for (let index = 0; index < megabytes; index++) {
|
||||
await write("event_msg", {
|
||||
type: "agent_message",
|
||||
message: "x".repeat(1024 * 1024),
|
||||
phase: "commentary",
|
||||
});
|
||||
}
|
||||
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
|
||||
await rollout.close();
|
||||
if (historyMode === "paginated") {
|
||||
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
|
||||
);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: root },
|
||||
{ sessionId: threadId },
|
||||
pino({ level: "silent" }),
|
||||
async () =>
|
||||
spawn(
|
||||
"codex",
|
||||
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
},
|
||||
),
|
||||
{ codexHome: home },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"history",
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
let count = 0;
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type !== "timeline") continue;
|
||||
count++;
|
||||
if (event.item.type === "notification") console.log(event.item.message);
|
||||
}
|
||||
console.log(`History loaded: ${count} timeline rows`);
|
||||
} catch (error) {
|
||||
// A recoverable session error is acceptable for the crash-isolation assertion.
|
||||
console.log(
|
||||
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
@@ -247,7 +247,7 @@ second line'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists each sub-agent as its launch marker", () => {
|
||||
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "assistant_message", text: "Before first child." },
|
||||
toolCallItem({
|
||||
@@ -257,7 +257,7 @@ second line'`,
|
||||
type: "sub_agent",
|
||||
subAgentType: "Child one",
|
||||
description: "First investigation",
|
||||
log: "",
|
||||
log: "[Assistant] First child result.",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "Between children." },
|
||||
@@ -268,7 +268,7 @@ second line'`,
|
||||
type: "sub_agent",
|
||||
subAgentType: "Child two",
|
||||
description: "Second investigation",
|
||||
log: "",
|
||||
log: "[Assistant] Second child result.",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "After second child." },
|
||||
@@ -279,8 +279,10 @@ second line'`,
|
||||
expect(result.split("\n")).toEqual([
|
||||
"[Assistant] Before first child.",
|
||||
"[Child one] First investigation",
|
||||
"[Assistant] First child result.",
|
||||
"[Assistant] Between children.",
|
||||
"[Child two] Second investigation",
|
||||
"[Assistant] Second child result.",
|
||||
"[Assistant] After second child.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -164,6 +164,9 @@ function curateProjectedActivityEntries(
|
||||
break;
|
||||
}
|
||||
entries.push(formatToolCallEntry(item, options));
|
||||
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
|
||||
entries.push(activityEntry(item.detail.log.trim()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "todo":
|
||||
|
||||
@@ -8059,60 +8059,6 @@ test("global subscribers receive internal agent events only when they opt in", a
|
||||
unsubscribeOptedIn();
|
||||
});
|
||||
|
||||
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
|
||||
const manager = new AgentManager({ clients: {}, logger });
|
||||
const removedId = "00000000-0000-4000-8000-000000000901";
|
||||
const internalId = "00000000-0000-4000-8000-000000000902";
|
||||
const otherId = "00000000-0000-4000-8000-000000000903";
|
||||
const publicEvents: AgentManagerEvent[] = [];
|
||||
const allEvents: AgentManagerEvent[] = [];
|
||||
const scopedEvents: AgentManagerEvent[] = [];
|
||||
const internalEvents: AgentManagerEvent[] = [];
|
||||
const unrelatedEvents: AgentManagerEvent[] = [];
|
||||
const releases = [
|
||||
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
|
||||
manager.subscribe((event) => allEvents.push(event), {
|
||||
replayState: false,
|
||||
includeInternal: true,
|
||||
}),
|
||||
manager.subscribe((event) => scopedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: removedId,
|
||||
}),
|
||||
manager.subscribe((event) => internalEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: internalId,
|
||||
}),
|
||||
manager.subscribe((event) => unrelatedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: otherId,
|
||||
}),
|
||||
];
|
||||
try {
|
||||
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
|
||||
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
|
||||
const publicRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: removedId,
|
||||
workspaceId: "workspace",
|
||||
internal: false,
|
||||
};
|
||||
const internalRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: internalId,
|
||||
workspaceId: "workspace",
|
||||
internal: true,
|
||||
};
|
||||
expect(publicEvents).toEqual([publicRemoval]);
|
||||
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
|
||||
expect(scopedEvents).toEqual([publicRemoval]);
|
||||
expect(internalEvents).toEqual([internalRemoval]);
|
||||
expect(unrelatedEvents).toEqual([]);
|
||||
} finally {
|
||||
for (const release of releases) release();
|
||||
}
|
||||
});
|
||||
|
||||
test("getAgent returns internal agents by ID", async () => {
|
||||
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
|
||||
@@ -236,7 +236,6 @@ export type {
|
||||
|
||||
export type AgentManagerEvent =
|
||||
| { type: "agent_state"; agent: ManagedAgent }
|
||||
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
||||
| {
|
||||
@@ -3520,15 +3519,6 @@ export class AgentManager {
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
}
|
||||
|
||||
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
|
||||
this.dispatch({
|
||||
type: "agent_removed",
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
internal: agent.internal === true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||
await this.durableTimelineStore?.deleteAgent(agentId);
|
||||
}
|
||||
@@ -5386,7 +5376,7 @@ export class AgentManager {
|
||||
for (const subscriber of this.subscribers) {
|
||||
if (
|
||||
subscriber.agentId &&
|
||||
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
||||
event.type === "agent_stream" &&
|
||||
subscriber.agentId !== event.agentId
|
||||
) {
|
||||
continue;
|
||||
@@ -5422,7 +5412,6 @@ export class AgentManager {
|
||||
|
||||
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
||||
if (event.type === "agent_state") return event.agent.internal === true;
|
||||
if (event.type === "agent_removed") return event.internal;
|
||||
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
||||
if (event.type !== "provider_subagent") return false;
|
||||
const parentAgentId =
|
||||
|
||||
@@ -313,10 +313,7 @@ export type ToolCallDetail =
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
|
||||
// require `log`; every producer sends it empty and carries the same tag. Optional here because
|
||||
// protocol timeline items, where `log` is optional, flow into this type.
|
||||
log?: string;
|
||||
log: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
@@ -253,52 +251,23 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
async function measureTimelineTail(wholeTurns: boolean) {
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
|
||||
blockingMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
|
||||
let unrelatedWork: ReturnType<typeof setImmediate>;
|
||||
const burnCpu = () => {
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
const start = threadCpuUsage();
|
||||
let elapsed: NodeJS.CpuUsage;
|
||||
do {
|
||||
elapsed = threadCpuUsage(start);
|
||||
} while (elapsed.user + elapsed.system < 80_000);
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
};
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
try {
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(false);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
} finally {
|
||||
clearImmediate(unrelatedWork);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
const { result: tail, blockingMs } = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
|
||||
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["before", "after"])(
|
||||
"keeps one Task card when child tools start %s the parent finishes",
|
||||
"keeps one logged Task card when child tools start %s the parent finishes",
|
||||
async (timing) => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+25
-23
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
|
||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
@@ -307,7 +307,9 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
|
||||
expect(latest.detail.subAgentType).toBe("Explore");
|
||||
expect(latest.detail.description).toBe("Inspect repository structure");
|
||||
expect(latest.detail.log).toBe("");
|
||||
expect(latest.detail.log).toContain("[Read] README.md");
|
||||
expect(latest.detail.log).toContain("[Edit] src/index.ts");
|
||||
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
|
||||
|
||||
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
@@ -394,7 +396,10 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
.map((event) => event.item)
|
||||
.at(-1);
|
||||
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({
|
||||
type: "sub_agent",
|
||||
log: expect.stringContaining("[Read] README.md"),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
|
||||
@@ -548,7 +553,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
|
||||
});
|
||||
|
||||
test("keeps the Task card the same size while the child runs", async () => {
|
||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
|
||||
const session = await new ClaudeAgentClient({
|
||||
@@ -563,27 +568,24 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||
await session.close();
|
||||
|
||||
const runningCards = events
|
||||
.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.callId === "task-tail-1" &&
|
||||
event.item.status === "running" &&
|
||||
event.item.detail.type === "sub_agent"
|
||||
? [event.item]
|
||||
: [],
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call",
|
||||
)
|
||||
.map((item) => JSON.stringify(item));
|
||||
expect(runningCards).toHaveLength(1);
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-tail-1");
|
||||
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
expect(latest).toBeDefined();
|
||||
if (!latest || latest.detail.type !== "sub_agent") {
|
||||
throw new Error("expected sub_agent detail");
|
||||
}
|
||||
|
||||
const childToolCalls = events.filter(
|
||||
(event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.id === "task-tail-1" &&
|
||||
event.event.item.type === "tool_call",
|
||||
);
|
||||
expect(childToolCalls).toHaveLength(205);
|
||||
expect(latest.detail.log).not.toContain("[Read] file-1.md");
|
||||
expect(latest.detail.log).not.toContain("[Read] file-5.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-6.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-205.md");
|
||||
});
|
||||
|
||||
test("keeps a long main-thread tool call out of the subagents track", async () => {
|
||||
|
||||
@@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
/**
|
||||
* The Task card in the parent transcript, built from the declaration.
|
||||
*
|
||||
* The sidechain tracker also emits this card when it first sees the child, and the two
|
||||
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
|
||||
* collapse on the shared tool-call id. Emitting it at declaration matters because a
|
||||
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
|
||||
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
|
||||
@@ -6276,7 +6276,6 @@ function buildClaudeSubagentCardDetail(
|
||||
type: "sub_agent",
|
||||
...(facts.title ? { subAgentType: facts.title } : {}),
|
||||
...(facts.description ? { description: facts.description } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
|
||||
describe("ClaudeSidechainTracker", () => {
|
||||
it("keeps a background child's card across the parent finishing", () => {
|
||||
it("retains a background child's actions across the parent finishing", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||
isDescriptorOwnedElsewhere: () => true,
|
||||
@@ -15,67 +15,27 @@ describe("ClaudeSidechainTracker", () => {
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||
}) as unknown as SDKMessage;
|
||||
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||
events.filter((event) => event.type === "timeline");
|
||||
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item.callId]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
|
||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
||||
expect(tracker.finishAll("completed")).toEqual([]);
|
||||
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(parentCards(afterFinish)).toEqual([]);
|
||||
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
|
||||
|
||||
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
tracker.delete("task-1");
|
||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||
expect(parentCards(next)).toHaveLength(1);
|
||||
expect(childCallIds(next)).toEqual(["next"]);
|
||||
});
|
||||
|
||||
it("shows a streamed child tool call's input once the assistant message brings it", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as SDKMessage[];
|
||||
|
||||
const childCards = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childCards.at(-1)).toMatchObject({
|
||||
callId: "bash-1",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "echo child" },
|
||||
});
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
@@ -19,17 +20,21 @@ interface ClaudeContentChunk {
|
||||
|
||||
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
interface SubAgentChildCall {
|
||||
interface SubAgentActionEntry {
|
||||
index: number;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface SubAgentActivityState {
|
||||
name?: string;
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
/** The child's tool calls awaiting results, which carry no input of their own. */
|
||||
runningCalls: Map<string, SubAgentChildCall>;
|
||||
actions: SubAgentActionEntry[];
|
||||
actionKeys: string[];
|
||||
nextActionIndex: number;
|
||||
actionIndexByKey: Map<string, number>;
|
||||
completedActionKeys: Set<string>;
|
||||
}
|
||||
|
||||
@@ -39,6 +44,7 @@ interface SubAgentActionCandidate {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
@@ -73,7 +79,8 @@ export class ClaudeSidechainTracker {
|
||||
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
|
||||
/**
|
||||
* The parent's tool call once Claude has answered it. A background child streams its frames
|
||||
* after that answer, so its card keeps the settled status and output.
|
||||
* after that answer, so its card keeps the settled status and output and gains the child's
|
||||
* action log.
|
||||
*/
|
||||
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||
}) {
|
||||
@@ -84,27 +91,28 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||
const existingState = this.activeSidechains.get(parentToolUseId);
|
||||
const state =
|
||||
existingState ??
|
||||
this.activeSidechains.get(parentToolUseId) ??
|
||||
({
|
||||
runningCalls: new Map<string, SubAgentChildCall>(),
|
||||
actions: [],
|
||||
actionKeys: [],
|
||||
nextActionIndex: 1,
|
||||
actionIndexByKey: new Map<string, number>(),
|
||||
completedActionKeys: new Set<string>(),
|
||||
} satisfies SubAgentActivityState);
|
||||
this.activeSidechains.set(parentToolUseId, state);
|
||||
|
||||
// The parent card is a launch marker: it changes only when the child is first seen or the
|
||||
// task input names it differently, never with the child's activity.
|
||||
const cardUpdated =
|
||||
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
for (const action of actionCandidates) {
|
||||
if (state.completedActionKeys.has(action.key)) continue;
|
||||
if (this.trackChildCall(state, action)) {
|
||||
if (this.appendSubAgentAction(state, action)) {
|
||||
actionUpdated = true;
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: action.toolName,
|
||||
callId: action.key,
|
||||
@@ -117,7 +125,7 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardUpdated && childTimelineItems.length === 0) {
|
||||
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -137,8 +145,11 @@ export class ClaudeSidechainTracker {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n"),
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -159,19 +170,18 @@ export class ClaudeSidechainTracker {
|
||||
},
|
||||
];
|
||||
|
||||
const parentCard: AgentStreamEvent[] =
|
||||
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
provider: "claude",
|
||||
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
provider: "claude",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...descriptorEvents,
|
||||
@@ -293,7 +303,8 @@ export class ClaudeSidechainTracker {
|
||||
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
|
||||
const callId = readTrimmedString(block.tool_use_id);
|
||||
if (!callId || state.completedActionKeys.has(callId)) continue;
|
||||
const action = state.runningCalls.get(callId);
|
||||
const actionIndex = state.actionIndexByKey.get(callId);
|
||||
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
|
||||
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
|
||||
if (!toolName) continue;
|
||||
const params = {
|
||||
@@ -306,7 +317,6 @@ export class ClaudeSidechainTracker {
|
||||
? mapClaudeFailedToolCall({ ...params, error: block })
|
||||
: mapClaudeCompletedToolCall(params);
|
||||
if (toolCall) {
|
||||
state.runningCalls.delete(callId);
|
||||
state.completedActionKeys.add(callId);
|
||||
items.push(toolCall);
|
||||
}
|
||||
@@ -402,12 +412,13 @@ export class ClaudeSidechainTracker {
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
|
||||
const input =
|
||||
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
|
||||
? block.input
|
||||
: null;
|
||||
return [{ key, toolName: block.name, input }];
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
@@ -437,20 +448,84 @@ export class ClaudeSidechainTracker {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns whether the call is new or has gained its input, so its running card changes. */
|
||||
private trackChildCall(
|
||||
private appendSubAgentAction(
|
||||
state: SubAgentActivityState,
|
||||
candidate: SubAgentActionCandidate,
|
||||
): boolean {
|
||||
const toolName = readTrimmedString(candidate.toolName);
|
||||
if (!toolName) {
|
||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||
if (!normalizedToolName) {
|
||||
return false;
|
||||
}
|
||||
const existing = state.runningCalls.get(candidate.key);
|
||||
if (existing && (existing.input !== null || candidate.input === null)) {
|
||||
return false;
|
||||
|
||||
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = state.actions[existingIndex];
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
const nextSummary = existing.summary ?? summary;
|
||||
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
|
||||
return false;
|
||||
}
|
||||
state.actions[existingIndex] = {
|
||||
...existing,
|
||||
toolName: normalizedToolName,
|
||||
input: existing.input ?? candidate.input,
|
||||
...(nextSummary ? { summary: nextSummary } : {}),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
|
||||
|
||||
state.actions.push({
|
||||
index: state.nextActionIndex,
|
||||
toolName: normalizedToolName,
|
||||
input: candidate.input,
|
||||
...(summary ? { summary } : {}),
|
||||
});
|
||||
state.nextActionIndex += 1;
|
||||
state.actionKeys.push(candidate.key);
|
||||
this.trimSubAgentTail(state);
|
||||
this.rebuildSubAgentActionIndex(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
private trimSubAgentTail(state: SubAgentActivityState): void {
|
||||
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
||||
state.actions.shift();
|
||||
const removedKey = state.actionKeys.shift();
|
||||
if (removedKey) state.completedActionKeys.delete(removedKey);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
|
||||
state.actionIndexByKey.clear();
|
||||
for (let index = 0; index < state.actionKeys.length; index += 1) {
|
||||
const key = state.actionKeys[index];
|
||||
if (key) {
|
||||
state.actionIndexByKey.set(key, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
input,
|
||||
output: null,
|
||||
});
|
||||
if (!runningToolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: runningToolCall.name,
|
||||
status: runningToolCall.status,
|
||||
error: runningToolCall.error,
|
||||
detail: runningToolCall.detail,
|
||||
metadata: runningToolCall.metadata,
|
||||
});
|
||||
return this.normalizeSubAgentText(display.summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,19 +77,12 @@ CHILD_SENTINEL in your own response.
|
||||
|
||||
const completedChildIndex = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.status === "completed",
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed" &&
|
||||
event.item.detail.type === "sub_agent" &&
|
||||
event.item.detail.log.includes("CHILD_SENTINEL"),
|
||||
);
|
||||
const childAssistantText = events
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "assistant_message"
|
||||
? [event.event.item.text]
|
||||
: [],
|
||||
)
|
||||
.join("");
|
||||
const rootMessageIndexes = events.flatMap((event, index) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
|
||||
);
|
||||
@@ -112,7 +105,6 @@ CHILD_SENTINEL in your own response.
|
||||
.map((item) => item.text)
|
||||
.join("");
|
||||
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
|
||||
expect(childAssistantText).toContain("CHILD_SENTINEL");
|
||||
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
|
||||
expect(terminalIndexes).toHaveLength(1);
|
||||
|
||||
@@ -88,7 +88,6 @@ describe("Codex executable discovery", () => {
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
fakeCodexThreadHistory,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
waitForNextTimelineItem,
|
||||
@@ -1992,7 +1991,7 @@ describe("Codex app-server provider", () => {
|
||||
purpose: "history",
|
||||
});
|
||||
|
||||
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
|
||||
expect(threadRequests).toEqual(["thread/read"]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
@@ -2069,7 +2068,6 @@ describe("Codex app-server provider", () => {
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
"thread/read",
|
||||
"thread/read",
|
||||
]);
|
||||
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
|
||||
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
|
||||
@@ -2429,7 +2427,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: true,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2443,9 +2441,8 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
...fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
"thread/rollback": () => {
|
||||
throw new Error("paginated threads do not support thread/rollback");
|
||||
@@ -2490,7 +2487,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: true,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2507,10 +2504,11 @@ describe("Codex app-server provider", () => {
|
||||
async (historyMode) => {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
historyMode === "paginated"
|
||||
? fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
})
|
||||
? {
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const customCodexConfig = {
|
||||
@@ -3524,7 +3522,7 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
|
||||
test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -3562,6 +3560,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
const timelineEvents = events.filter((event) => event.type === "timeline");
|
||||
expect(timelineEvents).toHaveLength(4);
|
||||
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
|
||||
const finalItem = timelineEvents.at(-1)?.item;
|
||||
expect(finalItem).toMatchObject({
|
||||
@@ -3573,7 +3572,7 @@ describe("Codex app-server provider", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Report findings.",
|
||||
log: "",
|
||||
log: "[Assistant] Found the path.",
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
@@ -3650,9 +3649,13 @@ describe("Codex app-server provider", () => {
|
||||
id: "settled-child-thread",
|
||||
status: "completed",
|
||||
});
|
||||
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: { callId: "call-settled-child", status: "completed" },
|
||||
item: {
|
||||
callId: "call-settled-child",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
|
||||
},
|
||||
});
|
||||
|
||||
appServer.completesSubAgentActivity({
|
||||
@@ -3957,8 +3960,6 @@ describe("Codex app-server provider", () => {
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
const resultPromise = session.run("Delegate the investigation, then report the result.");
|
||||
@@ -4002,18 +4003,11 @@ describe("Codex app-server provider", () => {
|
||||
type: "tool_call",
|
||||
callId: "spawn-child-early",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
log: "[Assistant] Early child findings.",
|
||||
},
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: "child-thread-early",
|
||||
item: expect.objectContaining({ type: "assistant_message" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -4084,7 +4078,10 @@ describe("Codex app-server provider", () => {
|
||||
expect(toolCalls.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-stable",
|
||||
status: "canceled",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
log: "[Assistant] Still on the same card.",
|
||||
},
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
@@ -4164,10 +4161,11 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
const result = await resultPromise;
|
||||
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
|
||||
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
|
||||
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
|
||||
expect(toolCalls.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-late-compaction",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
@@ -4175,7 +4173,7 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes legacy child tools to the child timeline", async () => {
|
||||
test("projects legacy child tools into one stable sub-agent log", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
@@ -4183,8 +4181,6 @@ describe("Codex app-server provider", () => {
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
const resultPromise = session.run("Delegate the implementation.");
|
||||
@@ -4227,26 +4223,19 @@ describe("Codex app-server provider", () => {
|
||||
expect(finalToolCall).toMatchObject({
|
||||
callId: "spawn-legacy-tool-child",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent" },
|
||||
});
|
||||
const childToolCalls = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.id === "legacy-tool-child" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
|
||||
new Set(["shell", "edit"]),
|
||||
);
|
||||
if (finalToolCall?.detail.type === "sub_agent") {
|
||||
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
|
||||
expect(finalToolCall.detail.log).toContain("[Edit]");
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
|
||||
test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -4306,22 +4295,8 @@ describe("Codex app-server provider", () => {
|
||||
expect(beforeParentCompletes.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-root",
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "grandchild-thread",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
messageId: "grandchild-message",
|
||||
text: "Grandchild findings.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await asInternals(session).handleNotification("turn/completed", {
|
||||
threadId: "child-thread-root",
|
||||
@@ -4388,20 +4363,13 @@ describe("Codex app-server provider", () => {
|
||||
"child-thread-root",
|
||||
"child-thread-root",
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "grandchild-thread",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
messageId: "grandchild-after-wait",
|
||||
text: "Still nested.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
callId: "spawn-child-root",
|
||||
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("never treats an unmapped foreign terminal as the root terminal", async () => {
|
||||
@@ -4821,7 +4789,7 @@ describe("Codex app-server provider", () => {
|
||||
expect(events).toHaveLength(eventCountAfterSpawn);
|
||||
});
|
||||
|
||||
test("keeps child context compaction on the child timeline", async () => {
|
||||
test("keeps child context compaction inside the child card", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -4854,70 +4822,10 @@ describe("Codex app-server provider", () => {
|
||||
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "child-thread-compaction",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
id: "child-thread-compaction",
|
||||
item: { type: "compaction", status: "completed" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
for (const child of ["compacted-child", "turn-ended-child"]) {
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "subAgentActivity",
|
||||
id: `spawn-${child}`,
|
||||
kind: "started",
|
||||
agentThreadId: child,
|
||||
agentPath: `/root/${child}`,
|
||||
},
|
||||
});
|
||||
await asInternals(session).handleNotification("item/started", {
|
||||
threadId: child,
|
||||
item: { type: "contextCompaction", id: `${child}-compaction` },
|
||||
});
|
||||
}
|
||||
await asInternals(session).handleNotification("thread/compacted", {
|
||||
threadId: "compacted-child",
|
||||
expect(timelineItems.at(-1)).toMatchObject({
|
||||
type: "tool_call",
|
||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
||||
});
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "compacted-child",
|
||||
item: { type: "contextCompaction", id: "compacted-child-compaction" },
|
||||
});
|
||||
await asInternals(session).handleNotification("turn/completed", {
|
||||
threadId: "turn-ended-child",
|
||||
turn: { status: "completed" },
|
||||
});
|
||||
|
||||
const compactions = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [
|
||||
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(compactions).toEqual([
|
||||
"compacted-child loading",
|
||||
"turn-ended-child loading",
|
||||
"compacted-child completed",
|
||||
"turn-ended-child completed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
|
||||
@@ -4952,7 +4860,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
|
||||
expect(events.at(-1)?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-child-command-failure",
|
||||
name: "Sub-agent",
|
||||
@@ -5039,7 +4947,6 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
|
||||
expect(requests.map((request) => [request.method, request.params])).toEqual([
|
||||
["thread/read", { threadId: "test-thread", includeTurns: false }],
|
||||
["thread/read", { threadId: "test-thread", includeTurns: true }],
|
||||
]);
|
||||
expect(history).toEqual([
|
||||
@@ -5247,7 +5154,7 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
expect(liveToolCalls.at(-1)).toMatchObject({
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
|
||||
});
|
||||
|
||||
liveEvents.length = 0;
|
||||
@@ -5260,12 +5167,12 @@ describe("Codex app-server provider", () => {
|
||||
itemId: "legacy-child-message-after-resume",
|
||||
delta: "Legacy findings after resume.",
|
||||
});
|
||||
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||
expect(liveEvents.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
callId: "legacy-spawn-history",
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,10 @@ import { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||
import {
|
||||
mapCodexToolCallEnvelope,
|
||||
@@ -92,18 +92,12 @@ import {
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
type CodexHistoryRow,
|
||||
CodexThreadHistoryTooLargeError,
|
||||
readCodexThreadHistory,
|
||||
} from "./codex/thread-history.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
} from "./provider-image-output.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -1810,13 +1804,22 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to the row's start time for a user message and its end time otherwise. */
|
||||
function readCodexHistoryRowTimestamp(
|
||||
row: CodexHistoryRow,
|
||||
function readCodexTurnHistoryTimestamp(
|
||||
turn: unknown,
|
||||
timelineItem: AgentTimelineItem,
|
||||
): string | null {
|
||||
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
|
||||
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
|
||||
const record = toObjectRecord(turn);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt =
|
||||
normalizeProviderReplayTimestamp(record.startedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.started_at);
|
||||
const completedAt =
|
||||
normalizeProviderReplayTimestamp(record.completedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.completed_at);
|
||||
|
||||
if (timelineItem.type === "user_message") {
|
||||
return startedAt ?? completedAt;
|
||||
}
|
||||
@@ -2060,19 +2063,84 @@ async function threadItemToTimelineEntries(
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
|
||||
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
|
||||
|
||||
async function requestCodexThreadHistory(
|
||||
requestThread: CodexThreadReadRequest,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadReadResponse> {
|
||||
const response = await requestThread(threadId);
|
||||
return CodexThreadReadResponseSchema.parse(response);
|
||||
}
|
||||
|
||||
async function loadCodexThreadHistoryTimeline(params: {
|
||||
client: CodexAppServerClientLike;
|
||||
threadId: string;
|
||||
cwd: string | null;
|
||||
requestThread: CodexThreadReadRequest;
|
||||
}): Promise<CodexThreadHistoryProjection> {
|
||||
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
const subAgentTimelineIndexByThreadId = new Map<string, number>();
|
||||
try {
|
||||
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 } });
|
||||
for (const turn of response.thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
item: settledTimelineItem,
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||
? { providerTurnId: turn.id }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
|
||||
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
|
||||
@@ -2085,55 +2153,11 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
return { timeline, subAgentRoutes };
|
||||
}
|
||||
|
||||
async function projectCodexThreadHistory(
|
||||
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
|
||||
projection: {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
subAgentTimelineIndexByThreadId: Map<string, number>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { timeline, subAgentTimelineIndexByThreadId } = projection;
|
||||
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
|
||||
const { item } = row;
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
// Cap each item as it is projected so a large history is never held uncapped.
|
||||
item: limitAgentTimelineItemContent(settledTimelineItem),
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && row.turnId !== undefined
|
||||
? { providerTurnId: row.turnId }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
|
||||
return client.request("thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function forkCodexThread(
|
||||
@@ -3384,6 +3408,8 @@ interface CodexSubAgentCallState {
|
||||
activityItemIds: Set<string>;
|
||||
pendingCommandOutputDeltas: Map<string, string[]>;
|
||||
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
||||
childItemOrder: string[];
|
||||
childItems: Map<string, AgentTimelineItem>;
|
||||
childThreadIds: Set<string>;
|
||||
}
|
||||
|
||||
@@ -3485,9 +3511,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
private pendingRootCompactionItemIds = new Set<string>();
|
||||
private pendingAnonymousRootCompactions = 0;
|
||||
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
|
||||
// `thread/compacted` or the end of the child's turn.
|
||||
private pendingChildCompactionThreadIds = new Set<string>();
|
||||
// Codex can report one completed compaction through both channels:
|
||||
// `thread/compacted` and a completed `contextCompaction` item.
|
||||
private unpairedCompactionNotificationCompletions = 0;
|
||||
@@ -3974,9 +3997,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return { timeline: [], providerSubagentEvents: [] };
|
||||
}
|
||||
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: this.currentThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
|
||||
});
|
||||
this.subAgentCallsByCallId.clear();
|
||||
this.subAgentCallIdByChildThreadId.clear();
|
||||
@@ -4032,9 +4055,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
try {
|
||||
const childHistory = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: next.route.childThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
|
||||
});
|
||||
for (const entry of childHistory.timeline) {
|
||||
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
|
||||
@@ -5517,9 +5540,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "patch_apply_completed":
|
||||
this.handlePatchApplyCompletedNotification(parsed, callId);
|
||||
return;
|
||||
case "context_compacted":
|
||||
this.settleChildCompaction(parsed.threadId);
|
||||
return;
|
||||
default:
|
||||
// Aggregate child telemetry is redundant and must not leak into the
|
||||
// root timeline. Concrete legacy tools are projected above for Codex
|
||||
@@ -5696,10 +5716,20 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
activityItemIds: new Set<string>(),
|
||||
pendingCommandOutputDeltas: new Map<string, string[]>(),
|
||||
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
||||
childItemOrder: [],
|
||||
childItems: new Map<string, AgentTimelineItem>(),
|
||||
childThreadIds: new Set<string>(),
|
||||
} satisfies CodexSubAgentCallState);
|
||||
|
||||
state.toolCall = timelineItem;
|
||||
state.toolCall = {
|
||||
...timelineItem,
|
||||
detail: {
|
||||
...timelineItem.detail,
|
||||
log:
|
||||
timelineItem.detail.log ||
|
||||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
|
||||
},
|
||||
};
|
||||
state.parentCallId ??= parentCallId;
|
||||
state.parentSubagentId ??= parentSubagentId;
|
||||
const activity = readCodexSubAgentActivity(rawItem);
|
||||
@@ -5752,17 +5782,25 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
const subAgentType =
|
||||
activityToolCall?.detail.type === "sub_agent"
|
||||
? activityToolCall.detail.subAgentType
|
||||
: undefined;
|
||||
if (
|
||||
activityToolCall?.detail.type === "sub_agent" &&
|
||||
state.toolCall.detail.type === "sub_agent"
|
||||
) {
|
||||
state.toolCall = {
|
||||
...state.toolCall,
|
||||
detail: {
|
||||
...state.toolCall.detail,
|
||||
subAgentType: activityToolCall.detail.subAgentType,
|
||||
},
|
||||
};
|
||||
}
|
||||
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||
if (activity.kind === "interrupted") {
|
||||
nextStatus = "canceled";
|
||||
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||
nextStatus = undefined;
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
|
||||
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5803,13 +5841,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): boolean {
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"completed",
|
||||
)
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -5819,6 +5851,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!state.childItems.has(itemId)) {
|
||||
state.childItemOrder.push(itemId);
|
||||
}
|
||||
state.childItems.set(itemId, item);
|
||||
}
|
||||
|
||||
private emitCodexToolTimelineItem(
|
||||
timelineItem: ToolCallTimelineItem,
|
||||
subAgentCallId: string | null,
|
||||
@@ -5828,6 +5871,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
|
||||
const state = this.subAgentCallsByCallId.get(subAgentCallId);
|
||||
if (state) {
|
||||
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
|
||||
@@ -5841,15 +5885,26 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
|
||||
return state.childItemOrder
|
||||
.map((itemId) => state.childItems.get(itemId))
|
||||
.filter((item): item is AgentTimelineItem => Boolean(item));
|
||||
}
|
||||
|
||||
private emitSubAgentActivityUpdate(
|
||||
callId: string,
|
||||
status?: ToolCallTimelineItem["status"],
|
||||
options?: { reopen?: boolean; subAgentType?: string },
|
||||
options?: { reopen?: boolean },
|
||||
): void {
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||
return;
|
||||
}
|
||||
const childTimeline = this.getSubAgentChildTimeline(state);
|
||||
const log =
|
||||
childTimeline.length > 0
|
||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
||||
: "";
|
||||
let resolvedStatus = status ?? state.toolCall.status;
|
||||
if (
|
||||
status === "running" &&
|
||||
@@ -5858,12 +5913,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
) {
|
||||
resolvedStatus = state.toolCall.status;
|
||||
}
|
||||
const baseToolCall = options?.subAgentType
|
||||
? {
|
||||
...state.toolCall,
|
||||
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
|
||||
}
|
||||
: state.toolCall;
|
||||
for (const childThreadId of state.childThreadIds) {
|
||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||
}
|
||||
const baseToolCall = {
|
||||
...state.toolCall,
|
||||
detail: {
|
||||
...state.toolCall.detail,
|
||||
log,
|
||||
},
|
||||
};
|
||||
const nextToolCall: ToolCallTimelineItem =
|
||||
resolvedStatus === "failed"
|
||||
? {
|
||||
@@ -5876,14 +5935,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
status: resolvedStatus,
|
||||
error: null,
|
||||
};
|
||||
// The card is a launch marker: the child's activity streams to its own timeline, so the card
|
||||
// is re-sent only when its status or label changes.
|
||||
const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall);
|
||||
state.toolCall = nextToolCall;
|
||||
for (const childThreadId of state.childThreadIds) {
|
||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||
}
|
||||
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
|
||||
if (state.parentCallId && state.parentCallId !== callId) {
|
||||
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
|
||||
this.emitSubAgentActivityUpdate(state.parentCallId);
|
||||
return;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
|
||||
@@ -5991,6 +6046,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||
if (itemId) {
|
||||
this.upsertSubAgentChildItem(callId, itemId, timelineItem);
|
||||
this.pendingAgentMessages.delete(itemId);
|
||||
this.pendingReasoning.delete(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
@@ -6001,29 +6057,19 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private handleSubAgentContextCompactionItem(
|
||||
callId: string,
|
||||
childThreadId: string,
|
||||
item: { id?: string; type?: string; [key: string]: unknown },
|
||||
status: "loading" | "completed",
|
||||
): boolean {
|
||||
if (!this.isContextCompactionItem(item)) {
|
||||
return false;
|
||||
}
|
||||
if (status === "loading") {
|
||||
this.pendingChildCompactionThreadIds.add(childThreadId);
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
|
||||
} else {
|
||||
this.settleChildCompaction(childThreadId);
|
||||
if (item.id) {
|
||||
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private settleChildCompaction(childThreadId: string): void {
|
||||
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSkipCompletedThreadItem(
|
||||
timelineItem: AgentTimelineItem,
|
||||
normalizedItemType: string | undefined,
|
||||
@@ -6054,6 +6100,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
text: parsed.delta,
|
||||
});
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||
type: "assistant_message",
|
||||
messageId: parsed.itemId,
|
||||
text,
|
||||
});
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
@@ -6087,6 +6138,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
text: parsed.delta,
|
||||
});
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||
type: "reasoning",
|
||||
text: prev.join(""),
|
||||
});
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
@@ -6154,7 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (subAgentCallId) {
|
||||
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
|
||||
let status: ToolCallTimelineItem["status"] = "completed";
|
||||
if (parsed.status === "failed") {
|
||||
status = "failed";
|
||||
@@ -6741,13 +6795,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"loading",
|
||||
)
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -6778,6 +6826,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
if (childSubAgentCallId) {
|
||||
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
|
||||
if (parsed.item.id) {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
@@ -6831,6 +6882,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (parsed.threadId) {
|
||||
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
|
||||
}
|
||||
if (itemId) {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
|
||||
// Opt in because the oversized cases need approximately 2 GiB of memory and over
|
||||
// 1 GiB of temporary storage.
|
||||
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
|
||||
const script = fileURLToPath(
|
||||
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
|
||||
);
|
||||
|
||||
test.runIf(enabled).each([
|
||||
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
|
||||
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
|
||||
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
|
||||
])(
|
||||
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
|
||||
async ({ megabytes, historyMode, expected }) => {
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
|
||||
(resolve) => {
|
||||
const child = execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx", script, String(megabytes), historyMode],
|
||||
{
|
||||
cwd: root,
|
||||
detached: true,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
||||
} catch (killError) {
|
||||
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
|
||||
}
|
||||
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(expected);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -185,41 +185,14 @@ describe("Codex app-server transport", () => {
|
||||
});
|
||||
|
||||
describe("Codex stdout byte admission", () => {
|
||||
test("an oversized response fails only its own request", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
|
||||
const other = client.request("model/list", {});
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
|
||||
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
|
||||
|
||||
expect(await oversized).toMatchObject({
|
||||
name: "CodexAppServerResponseTooLargeError",
|
||||
method: "thread/items/list",
|
||||
limitBytes: 128 * 1024 * 1024,
|
||||
});
|
||||
await expect(other).resolves.toEqual({ data: [] });
|
||||
const next = client.request("model/list", {});
|
||||
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
|
||||
await expect(next).resolves.toEqual({ data: ["next"] });
|
||||
expect(terminations).toEqual([]);
|
||||
await client.dispose();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
|
||||
test("an oversized notification fails pending requests and the session", async () => {
|
||||
test("fails pending requests and the session before joining an oversized line", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"method":"item/completed","params":"');
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
|
||||
const error = await request;
|
||||
expect(error).toMatchObject({
|
||||
|
||||
@@ -33,26 +33,11 @@ interface JsonRpcNotification {
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
method: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** A response past the provider line cap. Only its request fails; the session stays up. */
|
||||
export class CodexAppServerResponseTooLargeError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly limitBytes: number,
|
||||
) {
|
||||
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
|
||||
this.name = "CodexAppServerResponseTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Codex serializes a response with `id` first, so its head names the request.
|
||||
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
|
||||
|
||||
export class CodexAppServerRpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -273,7 +258,7 @@ export class CodexAppServerClient {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,21 +304,8 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
private handleStreamLimit(failure: ProviderStreamLimitError): void {
|
||||
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
|
||||
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(Number(responseId));
|
||||
this.logger.warn(
|
||||
{ method: pending.method, limitBytes: failure.limitBytes },
|
||||
"Codex app-server response exceeded the line cap",
|
||||
);
|
||||
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
|
||||
return;
|
||||
}
|
||||
// A lost notification or server request cannot be replayed. Fail the session,
|
||||
// A rejected response/notification cannot be replayed safely. Fail the session,
|
||||
// including live turns with no pending RPC, rather than dropping a stream unit.
|
||||
this.stdoutLineReader.close();
|
||||
this.handleUnexpectedTermination(failure);
|
||||
void this.dispose().catch((err) => {
|
||||
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: true,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: true,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import { readCodexThreadHistoryMode } from "./thread-history.js";
|
||||
|
||||
export interface CodexRewindClient {
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
@@ -21,6 +20,26 @@ export interface CodexUserMessageTurnIndex {
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
async function readCodexThreadHistoryMode(
|
||||
client: CodexRewindClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||
throw new Error("Codex thread/read did not return thread metadata");
|
||||
}
|
||||
const thread = response.thread;
|
||||
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||
return "legacy";
|
||||
}
|
||||
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||
return thread.historyMode;
|
||||
}
|
||||
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
@@ -76,8 +95,7 @@ export async function revertCodexConversation(input: {
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
// Paseo re-reads the rewound thread through paged history, not the fork reply.
|
||||
excludeTurns: true,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
|
||||
|
||||
/** A handler result that the fake writes as a response line past the provider line cap. */
|
||||
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||
@@ -233,18 +229,8 @@ export function createFakeCodexAppServer(
|
||||
return;
|
||||
}
|
||||
|
||||
let response: unknown;
|
||||
try {
|
||||
response = handler(message.params);
|
||||
} catch (error) {
|
||||
response = Promise.reject(error);
|
||||
}
|
||||
Promise.resolve(response)
|
||||
Promise.resolve(handler(message.params))
|
||||
.then((result) => {
|
||||
if (result === OVERSIZED_RESPONSE) {
|
||||
writeOversizedResponse(message.id as number);
|
||||
return undefined;
|
||||
}
|
||||
const rpcError = toJsonObject(result).__jsonRpcError;
|
||||
if (rpcError) {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
|
||||
@@ -327,16 +313,6 @@ export function createFakeCodexAppServer(
|
||||
});
|
||||
}
|
||||
|
||||
// Codex writes `id` first, then a result past the provider line cap.
|
||||
function writeOversizedResponse(id: number): void {
|
||||
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
|
||||
const chunk = Buffer.alloc(1024 * 1024, "x");
|
||||
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
|
||||
child.stdout.write(chunk);
|
||||
}
|
||||
child.stdout.write('"}}]}}\n');
|
||||
}
|
||||
|
||||
function writeNotification(method: string, params: JsonObject): void {
|
||||
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
|
||||
}
|
||||
@@ -720,81 +696,3 @@ export function waitForProviderSubagent(
|
||||
): Promise<ProviderSubagentEvent> {
|
||||
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryTurn {
|
||||
id: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
items: JsonObject[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryThread {
|
||||
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
|
||||
historyMode?: "legacy" | "paginated";
|
||||
turns: FakeCodexHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryOptions {
|
||||
/** Any response carrying one of these items is written past the line cap. */
|
||||
oversizedItemIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's persisted thread history: `thread/read` for metadata and legacy full
|
||||
* reads, `thread/items/list` for paginated threads. Paginated threads reject
|
||||
* the full read as Codex 0.145 and 0.146 do.
|
||||
*/
|
||||
export function fakeCodexThreadHistory(
|
||||
threads: Record<string, FakeCodexHistoryThread>,
|
||||
options: FakeCodexHistoryOptions = {},
|
||||
): Record<string, FakeCodexAppServerHandler> {
|
||||
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
|
||||
const isOversized = (items: JsonObject[]) =>
|
||||
items.some((item) => oversizedItemIds.has(String(item.id)));
|
||||
|
||||
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
|
||||
const id = String(toJsonObject(params).threadId);
|
||||
const thread = threads[id];
|
||||
if (!thread) throw new Error(`thread not found: ${id}`);
|
||||
return { id, thread };
|
||||
}
|
||||
|
||||
return {
|
||||
"thread/read": (params) => {
|
||||
const { id, thread } = readThread(params);
|
||||
if (!toJsonObject(params).includeTurns) {
|
||||
return {
|
||||
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
|
||||
};
|
||||
}
|
||||
if (thread.historyMode === "paginated") {
|
||||
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
|
||||
}
|
||||
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
|
||||
return { thread: { id, turns: thread.turns } };
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
const { thread } = readThread(params);
|
||||
if (thread.historyMode !== "paginated") {
|
||||
throw new Error("thread/items/list is not supported for legacy threads");
|
||||
}
|
||||
const request = toJsonObject(params);
|
||||
// Codex records each item's own times; the fake gives every item its turn's.
|
||||
const rows = thread.turns.flatMap((turn) =>
|
||||
turn.items.map((item) => ({
|
||||
turnId: turn.id,
|
||||
item,
|
||||
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
|
||||
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
|
||||
})),
|
||||
);
|
||||
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
|
||||
const start =
|
||||
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
|
||||
const end = Math.min(rows.length, start + limit);
|
||||
const data = rows.slice(start, end);
|
||||
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
|
||||
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
type FakeCodexHistoryOptions,
|
||||
type FakeCodexHistoryThread,
|
||||
fakeCodexThreadHistory,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
|
||||
function userItem(id: string, text: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function agentItem(id: string, text: string) {
|
||||
return { type: "agentMessage", id, text };
|
||||
}
|
||||
|
||||
async function openCodexThread(
|
||||
thread: FakeCodexHistoryThread,
|
||||
options?: FakeCodexHistoryOptions,
|
||||
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
fakeCodexThreadHistory({ "saved-thread": thread }, options),
|
||||
);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = provider as unknown as {
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
};
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
return { appServer, session };
|
||||
}
|
||||
|
||||
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) events.push(event);
|
||||
return events;
|
||||
}
|
||||
|
||||
function timelineItems(events: AgentStreamEvent[]) {
|
||||
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
function historyRequests(appServer: FakeCodexAppServer) {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter(
|
||||
(request) =>
|
||||
request.method === "thread/read" ||
|
||||
request.method === "thread/turns/list" ||
|
||||
request.method === "thread/items/list",
|
||||
)
|
||||
.map((request) => ({ method: request.method, params: request.params }));
|
||||
}
|
||||
|
||||
describe("Codex thread history", () => {
|
||||
test("loads a paginated thread through pages", async () => {
|
||||
const answers = Array.from({ length: 150 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-1",
|
||||
startedAt: 1_778_832_941,
|
||||
completedAt: 1_778_833_000,
|
||||
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
|
||||
},
|
||||
{
|
||||
id: "turn-2",
|
||||
startedAt: 1_778_833_094,
|
||||
completedAt: 1_778_833_100,
|
||||
items: [userItem("user-2", "Second"), ...answers.slice(120)],
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const events = await readHistory(session);
|
||||
|
||||
expect(timelineItems(events)).toEqual([
|
||||
{ type: "user_message", text: "First", messageId: "user-1" },
|
||||
...answers.slice(0, 120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Second", messageId: "user-2" },
|
||||
...answers.slice(120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
]);
|
||||
const timestamps = events.flatMap((event) =>
|
||||
event.type === "timeline" ? [event.timestamp] : [],
|
||||
);
|
||||
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
|
||||
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
|
||||
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
|
||||
const requests = historyRequests(appServer);
|
||||
expect(requests).not.toContainEqual(
|
||||
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
|
||||
);
|
||||
const itemPages = requests.filter((request) => request.method === "thread/items/list");
|
||||
expect(itemPages.length).toBeGreaterThan(1);
|
||||
for (const page of itemPages) {
|
||||
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
|
||||
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
|
||||
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
|
||||
],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: "The rest of this Codex thread's history is too large to load.",
|
||||
},
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a legacy thread that fits in one read", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
|
||||
"thread/items/list",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a legacy thread too big for one read shows the migration command", async () => {
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const migrationRow = {
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
|
||||
};
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
// The session stays up for the user to prompt until they archive it to migrate.
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
|
||||
|
||||
// Codex clamps history pages to 100 rows.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export interface CodexThreadHistoryClient {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
export interface CodexHistoryRow {
|
||||
item: unknown;
|
||||
turnId: string | undefined;
|
||||
/** The item's times on a paginated thread; its turn's on a legacy thread. */
|
||||
startedAt: unknown;
|
||||
completedAt: unknown;
|
||||
}
|
||||
|
||||
/** A history reply over the line cap; the rows before it were already read. */
|
||||
export class CodexThreadHistoryTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodexThreadHistoryTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
const CodexThreadMetadataSchema = z
|
||||
.object({
|
||||
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexLegacyThreadReadSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
startedAt: z.unknown().optional(),
|
||||
completedAt: z.unknown().optional(),
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexItemsPageSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
turnId: z.string(),
|
||||
item: z.unknown(),
|
||||
startedAtMs: z.number().nullable().optional(),
|
||||
completedAtMs: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
nextCursor: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
|
||||
export async function readCodexThreadHistoryMode(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = CodexThreadMetadataSchema.parse(
|
||||
await client.request("thread/read", { threadId, includeTurns: false }),
|
||||
);
|
||||
return response.thread.historyMode ?? "legacy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a thread's persisted items in order. Paginated threads are read in
|
||||
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
|
||||
* no pages for it.
|
||||
*/
|
||||
export async function* readCodexThreadHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
|
||||
yield* readPaginatedHistory(client, threadId);
|
||||
} else {
|
||||
yield* readLegacyHistory(client, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function* readLegacyHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const response = await requestHistory(
|
||||
client,
|
||||
"thread/read",
|
||||
{ threadId, includeTurns: true },
|
||||
`This Codex thread's history is too large to load in one read. ` +
|
||||
`To move it to paginated history, archive this agent, ` +
|
||||
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
|
||||
`then unarchive the agent.`,
|
||||
);
|
||||
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* readPaginatedHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
turnId: row.turnId,
|
||||
startedAt: row.startedAtMs,
|
||||
completedAt: row.completedAtMs,
|
||||
};
|
||||
}
|
||||
if (page.nextCursor == null) return;
|
||||
// Codex's own client guards against a cursor that does not advance.
|
||||
if (seenCursors.has(page.nextCursor)) {
|
||||
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
|
||||
}
|
||||
seenCursors.add(page.nextCursor);
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
tooLargeMessage: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await client.request(method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerResponseTooLargeError) {
|
||||
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -950,7 +950,6 @@ function mapCollabAgentToolCallItem(
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
...(item.prompt ? { description: item.prompt } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
@@ -1007,7 +1006,6 @@ function mapSubAgentActivityItem(
|
||||
type: "sub_agent",
|
||||
subAgentType: nativeName,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
// Provider-process stdout only; these are not app/daemon wire limits.
|
||||
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
|
||||
const OVERSIZED_LINE_HEAD_BYTES = 64;
|
||||
|
||||
export class ProviderStreamLimitError extends Error {
|
||||
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
|
||||
|
||||
constructor(
|
||||
readonly limitBytes: number,
|
||||
/** The first bytes of the oversized line; the rest is discarded unread. */
|
||||
readonly lineHead = "",
|
||||
) {
|
||||
constructor(readonly limitBytes: number) {
|
||||
super(
|
||||
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
|
||||
);
|
||||
@@ -19,26 +12,10 @@ export class ProviderStreamLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function readLineHead(parts: Buffer[]): string {
|
||||
const head: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for (const part of parts) {
|
||||
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
|
||||
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
|
||||
bytes += head[head.length - 1].byteLength;
|
||||
}
|
||||
return Buffer.concat(head).toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
|
||||
* An oversized line is reported with its head and skipped up to its newline;
|
||||
* the owner closes the reader if the stream cannot continue without it.
|
||||
*/
|
||||
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
|
||||
export class ProviderLineReader {
|
||||
private parts: Buffer[] = [];
|
||||
private bytes = 0;
|
||||
private skippingOversizedLine = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
@@ -54,20 +31,11 @@ export class ProviderLineReader {
|
||||
while (start < buffer.byteLength && !this.stopped) {
|
||||
const newline = buffer.indexOf(10, start);
|
||||
const end = newline === -1 ? buffer.byteLength : newline;
|
||||
if (this.skippingOversizedLine) {
|
||||
if (newline === -1) return;
|
||||
this.skippingOversizedLine = false;
|
||||
start = newline + 1;
|
||||
continue;
|
||||
}
|
||||
const length = end - start;
|
||||
if (length > this.limitBytes - this.bytes) {
|
||||
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
this.skippingOversizedLine = true;
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
|
||||
continue;
|
||||
this.close();
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
|
||||
return;
|
||||
}
|
||||
if (length) this.parts.push(buffer.subarray(start, end));
|
||||
this.bytes += length;
|
||||
@@ -182,10 +150,7 @@ export class JsonlFrameDecoder {
|
||||
(line) => {
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
},
|
||||
(error) => {
|
||||
this.lines.close();
|
||||
this.drop("line-too-large", error);
|
||||
},
|
||||
(error) => this.drop("line-too-large", error),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
type OmpRuntimeOptions,
|
||||
} from "./provider-config.js";
|
||||
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||
import { getUserMessageText } from "./message-history.js";
|
||||
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
|
||||
@@ -130,6 +130,7 @@ export interface OmpAgentClientOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
runtime?: OmpRuntime;
|
||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -204,6 +205,7 @@ interface OmpAgentSessionOptions {
|
||||
initialState: OmpSessionState;
|
||||
currentModeId?: string | null;
|
||||
logger: Logger;
|
||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -771,7 +773,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.logger.debug({ err: error }, "OMP context usage poll failed");
|
||||
},
|
||||
});
|
||||
this.subagentCardTracker = new OmpSubagentCardTracker();
|
||||
this.subagentCardTracker = new OmpSubagentCardTracker({
|
||||
scheduler: options.subagentCardScheduler,
|
||||
});
|
||||
this.lastKnownThinkingOptionId =
|
||||
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
|
||||
this.state.thinkingLevel ??
|
||||
@@ -2151,7 +2155,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
return mapOmpToolDetail(toolCall, result, {
|
||||
toolCallId,
|
||||
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
|
||||
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
|
||||
mapSubagentDetail: (detail) =>
|
||||
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2241,6 +2246,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
private readonly noTurnScheduler?: OmpNoTurnScheduler;
|
||||
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -2259,6 +2265,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
);
|
||||
this.logger = options.logger;
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.subagentCardScheduler = options.subagentCardScheduler;
|
||||
this.providerIdleScheduler = options.providerIdleScheduler;
|
||||
this.noTurnScheduler = options.noTurnScheduler;
|
||||
this.usagePollScheduler = options.usagePollScheduler;
|
||||
@@ -2342,6 +2349,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
initialState,
|
||||
currentModeId: launchMode.modeId,
|
||||
logger: this.logger,
|
||||
subagentCardScheduler: this.subagentCardScheduler,
|
||||
providerIdleScheduler: this.providerIdleScheduler,
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
@@ -2413,6 +2421,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
initialState,
|
||||
currentModeId: launchMode.modeId,
|
||||
logger: this.logger,
|
||||
subagentCardScheduler: this.subagentCardScheduler,
|
||||
providerIdleScheduler: this.providerIdleScheduler,
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
|
||||
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/omp-task/Explore.jsonl",
|
||||
log: "",
|
||||
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
const PARENT_TOOL_CALL_ID = "task-1";
|
||||
@@ -59,84 +59,184 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
|
||||
},
|
||||
];
|
||||
|
||||
class ManualScheduler implements OmpSubagentCardScheduler {
|
||||
private currentMs = 0;
|
||||
private nextId = 1;
|
||||
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
|
||||
|
||||
now(): number {
|
||||
return this.currentMs;
|
||||
}
|
||||
|
||||
setTimeout(callback: () => void, delayMs: number) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
|
||||
return { token: id };
|
||||
}
|
||||
|
||||
clearTimeout(timer: { token: unknown }): void {
|
||||
if (typeof timer.token === "number") {
|
||||
this.timers.delete(timer.token);
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
this.currentMs += ms;
|
||||
const dueTimers = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.dueMs <= this.currentMs)
|
||||
.sort((left, right) => left[1].dueMs - right[1].dueMs);
|
||||
for (const [id, timer] of dueTimers) {
|
||||
if (this.timers.delete(id)) {
|
||||
timer.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("OmpSubagentCardTracker", () => {
|
||||
test("keeps the card a launch marker while the child runs", () => {
|
||||
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
tracker.handleLifecycle(LIFECYCLE);
|
||||
for (const payload of PROGRESS) {
|
||||
tracker.handleProgress(payload, emit);
|
||||
tracker.handleProgress(payload);
|
||||
}
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
expect(
|
||||
tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
log: "",
|
||||
}),
|
||||
).toEqual({
|
||||
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
log: "",
|
||||
});
|
||||
expect(detailBeforeTrailing).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
log: [
|
||||
"EchoSubagent started",
|
||||
"[bash] echo subagent-hi",
|
||||
"[yield]",
|
||||
"EchoSubagent completed",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
test("re-emits the card when a later event names the child", () => {
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
|
||||
tracker.handleProgress(PROGRESS[0], emit);
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
type: "sub_agent",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
});
|
||||
|
||||
test("aggregates batch progress streams into one index-prefixed log", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.delete(PARENT_TOOL_CALL_ID);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
sessionFile: "/tmp/one.jsonl",
|
||||
parentToolCallId: "task.batch-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(600);
|
||||
tracker.handleProgress({
|
||||
index: 5,
|
||||
agent: "task",
|
||||
task: "Run tests",
|
||||
parentToolCallId: "task.batch-1",
|
||||
progress: {
|
||||
id: "Test",
|
||||
status: "running",
|
||||
description: "Run tests",
|
||||
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
|
||||
},
|
||||
sessionFile: "/tmp/two.jsonl",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task.batch-1", {
|
||||
type: "sub_agent",
|
||||
subAgentType: "batch",
|
||||
actions: [],
|
||||
log: "",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "sub_agent",
|
||||
log: "",
|
||||
subAgentType: "batch",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/one.jsonl",
|
||||
log: "[1/6] Explore started\n[6/6] [bash] npm test",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
test("leaves the card alone when a later child of the same task starts", () => {
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||
tracker.handleLifecycle(
|
||||
{
|
||||
...LIFECYCLE,
|
||||
id: "Second",
|
||||
index: 1,
|
||||
description: "Second child",
|
||||
sessionFile: "/s.jsonl",
|
||||
},
|
||||
emit,
|
||||
);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
type: "sub_agent",
|
||||
description: "Run echo in subagent",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
parentToolCallId: "task-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(100);
|
||||
tracker.handleProgress({
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Inspect files",
|
||||
parentToolCallId: "task-1",
|
||||
progress: {
|
||||
id: "Explore",
|
||||
status: "running",
|
||||
recentOutput: ["found target file"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "",
|
||||
}).log,
|
||||
).toBe("Explore started\nfound target file");
|
||||
|
||||
tracker.delete("task-1");
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "static",
|
||||
}).log,
|
||||
).toBe("static");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,106 +1,361 @@
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
interface OmpSubagentCardChild {
|
||||
export interface OmpSubagentCardTimer {
|
||||
readonly token: unknown;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardScheduler {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
|
||||
clearTimeout(timer: OmpSubagentCardTimer): void;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardTrackerOptions {
|
||||
scheduler?: OmpSubagentCardScheduler;
|
||||
emitToolCall?: (toolCallId: string) => boolean;
|
||||
}
|
||||
|
||||
interface OmpSubagentLogLine {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardItem {
|
||||
index: number;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
childSessionId?: string;
|
||||
lines: OmpSubagentLogLine[];
|
||||
lineKeys: Set<string>;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardState {
|
||||
items: Map<number, OmpSubagentCardItem>;
|
||||
emitToolCall: (toolCallId: string) => boolean;
|
||||
lastEmitMs: number | null;
|
||||
trailingTimer: OmpSubagentCardTimer | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES_PER_ITEM = 200;
|
||||
const MAX_LOG_LINE_CHARS = 240;
|
||||
const THROTTLE_INTERVAL_MS = 500;
|
||||
|
||||
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
|
||||
return {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
|
||||
clearTimeout: (timer) => {
|
||||
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
|
||||
* launch marker that names the task's first child; the children's activity belongs to their own
|
||||
* timelines.
|
||||
*/
|
||||
export class OmpSubagentCardTracker {
|
||||
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
|
||||
private readonly scheduler: OmpSubagentCardScheduler;
|
||||
private readonly emitToolCall: (toolCallId: string) => boolean;
|
||||
private readonly states = new Map<string, OmpSubagentCardState>();
|
||||
|
||||
constructor(options: OmpSubagentCardTrackerOptions) {
|
||||
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
|
||||
this.emitToolCall = options.emitToolCall ?? (() => false);
|
||||
}
|
||||
|
||||
handleLifecycle(
|
||||
payload: OmpSubagentLifecyclePayload,
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
this.update(
|
||||
payload.parentToolCallId,
|
||||
{
|
||||
index: payload.index,
|
||||
description: payload.description,
|
||||
childSessionId: payload.sessionFile,
|
||||
},
|
||||
emitToolCall,
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.description,
|
||||
status: payload.status === "started" ? "running" : payload.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
this.appendLine(
|
||||
item,
|
||||
`lifecycle:${payload.status}:${payload.id}`,
|
||||
`${payload.id} ${payload.status}`,
|
||||
);
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
handleProgress(
|
||||
payload: OmpSubagentProgressPayload,
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
this.update(
|
||||
payload.parentToolCallId,
|
||||
{
|
||||
index: payload.index,
|
||||
description: payload.progress.description,
|
||||
childSessionId: payload.sessionFile,
|
||||
},
|
||||
emitToolCall,
|
||||
);
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.progress.description,
|
||||
status: payload.progress.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
|
||||
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
|
||||
if (currentToolLine) {
|
||||
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
|
||||
}
|
||||
|
||||
for (const tool of readRecordArray(payload.progress.recentTools)) {
|
||||
const line = summarizeTool(tool);
|
||||
if (line) {
|
||||
this.appendLine(item, `tool:${line.key}`, line.text);
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of readOutputLines(payload.progress.recentOutput)) {
|
||||
this.appendLine(item, `output:${output}`, output);
|
||||
}
|
||||
|
||||
if (payload.progress.status !== "running") {
|
||||
this.appendLine(
|
||||
item,
|
||||
`status:${payload.progress.status}:${payload.progress.id}`,
|
||||
`${payload.progress.id} ${payload.progress.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
|
||||
if (baseDetail.type !== "sub_agent") {
|
||||
return baseDetail;
|
||||
}
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (!first) {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state) {
|
||||
return baseDetail;
|
||||
}
|
||||
const description = baseDetail.description ?? first.description;
|
||||
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
|
||||
return {
|
||||
...baseDetail,
|
||||
...(description ? { description } : {}),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) {
|
||||
return baseDetail;
|
||||
}
|
||||
|
||||
const detail: ToolCallDetail = {
|
||||
type: "sub_agent",
|
||||
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
|
||||
...((baseDetail.description ?? firstItem.description)
|
||||
? { description: baseDetail.description ?? firstItem.description }
|
||||
: {}),
|
||||
...((firstItem.childSessionId ?? baseDetail.childSessionId)
|
||||
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
|
||||
: {}),
|
||||
log: buildLog(items, baseDetail.log),
|
||||
};
|
||||
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
|
||||
}
|
||||
|
||||
flush(toolCallId: string): void {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state || !state.dirty) {
|
||||
return;
|
||||
}
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
|
||||
delete(toolCallId: string): void {
|
||||
this.firstChildren.delete(toolCallId);
|
||||
const state = this.states.get(toolCallId);
|
||||
if (state?.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
}
|
||||
this.states.delete(toolCallId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.firstChildren.clear();
|
||||
for (const toolCallId of this.states.keys()) {
|
||||
this.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
parentToolCallId: string | undefined,
|
||||
input: { index: number; description?: string; childSessionId?: string },
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
): void {
|
||||
const toolCallId = readTrimmedString(parentToolCallId);
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
private upsertItem(
|
||||
parentToolCallId: string,
|
||||
input: {
|
||||
index: number;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
},
|
||||
): OmpSubagentCardItem {
|
||||
const state = this.stateFor(parentToolCallId);
|
||||
const existing = state.items.get(input.index);
|
||||
if (existing) {
|
||||
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
|
||||
existing.status = input.status ?? existing.status;
|
||||
existing.description = readTrimmedString(input.description) ?? existing.description;
|
||||
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
|
||||
return existing;
|
||||
}
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (first && input.index > first.index) {
|
||||
return;
|
||||
}
|
||||
const existing = first?.index === input.index ? first : undefined;
|
||||
const next: OmpSubagentCardChild = {
|
||||
|
||||
const item: OmpSubagentCardItem = {
|
||||
index: input.index,
|
||||
description: readTrimmedString(input.description) ?? existing?.description,
|
||||
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
|
||||
lines: [],
|
||||
lineKeys: new Set<string>(),
|
||||
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(readTrimmedString(input.description)
|
||||
? { description: readTrimmedString(input.description) }
|
||||
: {}),
|
||||
...(readTrimmedString(input.childSessionId)
|
||||
? { childSessionId: readTrimmedString(input.childSessionId) }
|
||||
: {}),
|
||||
};
|
||||
if (
|
||||
existing &&
|
||||
existing.description === next.description &&
|
||||
existing.childSessionId === next.childSessionId
|
||||
) {
|
||||
state.items.set(input.index, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private stateFor(parentToolCallId: string): OmpSubagentCardState {
|
||||
const existing = this.states.get(parentToolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state: OmpSubagentCardState = {
|
||||
items: new Map(),
|
||||
emitToolCall: this.emitToolCall,
|
||||
lastEmitMs: null,
|
||||
trailingTimer: null,
|
||||
dirty: false,
|
||||
};
|
||||
this.states.set(parentToolCallId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
|
||||
const normalizedText = normalizeLine(text);
|
||||
if (!normalizedText || item.lineKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.firstChildren.set(toolCallId, next);
|
||||
emitToolCall(toolCallId);
|
||||
item.lines.push({ key, text: normalizedText });
|
||||
item.lineKeys.add(key);
|
||||
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
|
||||
const removed = item.lines.shift();
|
||||
if (removed) {
|
||||
item.lineKeys.delete(removed.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmit(toolCallId: string): void {
|
||||
const state = this.stateFor(toolCallId);
|
||||
const now = this.scheduler.now();
|
||||
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
|
||||
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
|
||||
this.emitNow(toolCallId, state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.dirty = true;
|
||||
if (state.trailingTimer) {
|
||||
return;
|
||||
}
|
||||
state.trailingTimer = this.scheduler.setTimeout(() => {
|
||||
state.trailingTimer = null;
|
||||
if (state.dirty) {
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
}, THROTTLE_INTERVAL_MS - elapsedMs);
|
||||
}
|
||||
|
||||
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
|
||||
if (state.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
state.trailingTimer = null;
|
||||
}
|
||||
state.dirty = false;
|
||||
state.lastEmitMs = this.scheduler.now();
|
||||
state.emitToolCall(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
|
||||
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
|
||||
const lines = items.flatMap((item) => {
|
||||
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
|
||||
return item.lines.map((line) => `${prefix}${line.text}`);
|
||||
});
|
||||
return lines.length > 0 ? lines.join("\n") : fallback;
|
||||
}
|
||||
|
||||
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
|
||||
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
|
||||
return {
|
||||
key: `${toolName}:${args ?? ""}${endMs}`,
|
||||
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
|
||||
};
|
||||
}
|
||||
|
||||
function readOutputLines(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((item) => {
|
||||
const text =
|
||||
readTrimmedString(item) ??
|
||||
(isRecord(item)
|
||||
? (readTrimmedString(item.text) ??
|
||||
readTrimmedString(item.output) ??
|
||||
readTrimmedString(item.content))
|
||||
: undefined);
|
||||
return text ? [text] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function summarizeUnknown(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return readTrimmedString(value);
|
||||
}
|
||||
const json = JSON.stringify(value);
|
||||
return typeof json === "string" ? normalizeLine(json) : undefined;
|
||||
}
|
||||
|
||||
function normalizeLine(value: string): string | undefined {
|
||||
const normalized = value.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_LOG_LINE_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
|
||||
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
|
||||
});
|
||||
|
||||
test("takes the child session from the task transcript path", () => {
|
||||
test("uses task result text and transcript path as the best static replay detail", () => {
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("task", {
|
||||
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Inspect the target files",
|
||||
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
|
||||
log: "",
|
||||
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ const OPAQUE_TEXT_TOOLS = new Set([
|
||||
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
|
||||
const argRecord = isRecord(args) ? args : {};
|
||||
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
|
||||
const resultText = extractTextFromToolResult(result);
|
||||
const childSessionId = readChildSessionId(result);
|
||||
return {
|
||||
type: "sub_agent",
|
||||
@@ -199,8 +200,7 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
|
||||
argRecord.assignment,
|
||||
),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: resultText?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4846,6 +4846,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
...item,
|
||||
status: "failed",
|
||||
error,
|
||||
detail:
|
||||
item.detail.type === "sub_agent"
|
||||
? {
|
||||
...item.detail,
|
||||
log: [item.detail.log, error.message]
|
||||
.filter((entry) => entry.trim().length > 0)
|
||||
.join("\n"),
|
||||
}
|
||||
: item.detail,
|
||||
},
|
||||
},
|
||||
turnId,
|
||||
|
||||
@@ -153,6 +153,21 @@ const OpencodeGrepOutputSchema = z
|
||||
])
|
||||
.nullable();
|
||||
|
||||
function formatLogEntry(value: unknown): string | undefined {
|
||||
const outputText = readOutputText(value);
|
||||
if (outputText) {
|
||||
return outputText;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
||||
const text = readOutputText(value);
|
||||
if (text) {
|
||||
@@ -180,6 +195,7 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
||||
function deriveOpencodeTaskDetail(
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
error: unknown,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): ToolCallDetail | null {
|
||||
if (!isRecord(input)) {
|
||||
@@ -192,6 +208,7 @@ function deriveOpencodeTaskDetail(
|
||||
return null;
|
||||
}
|
||||
|
||||
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
|
||||
const childSessionId =
|
||||
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
|
||||
return {
|
||||
@@ -199,8 +216,7 @@ function deriveOpencodeTaskDetail(
|
||||
...(subAgentType ? { subAgentType } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
@@ -357,10 +373,11 @@ export function deriveOpencodeToolDetail(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
error: unknown = null,
|
||||
metadata?: Record<string, unknown>,
|
||||
): ToolCallDetail {
|
||||
if (toolName.trim().toLowerCase() === "task") {
|
||||
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
|
||||
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
|
||||
if (taskDetail) {
|
||||
return taskDetail;
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps completed task calls to sub_agent detail", () => {
|
||||
it("maps completed task calls with final output log to sub_agent detail", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "task",
|
||||
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Explore agent-tools codebase",
|
||||
log: "",
|
||||
log: "Found the CLI entrypoint and provider registry.",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Explore current directory",
|
||||
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
||||
log: "",
|
||||
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps aborted task calls to sub_agent detail", () => {
|
||||
it("maps aborted task calls with preserved error log to sub_agent detail", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "task",
|
||||
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Explore agent-tools codebase",
|
||||
log: "",
|
||||
log: "Tool execution aborted",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
|
||||
const error = raw.error ?? null;
|
||||
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
||||
const status = normalizeToolCallStatus(rawStatus, error, output);
|
||||
const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata);
|
||||
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
|
||||
|
||||
if (status === "failed") {
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,47 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { UsageSourceRegistry } from "../../../../plugins/usage-sources/index.js";
|
||||
|
||||
test("#7116: v2 agent usage includes the plan windows available on the host", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
model: "openai/test-model",
|
||||
});
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: (id) => id === session.id,
|
||||
usageSession: (id) => (id === session.id ? (session.usageSession?.() ?? null) : null),
|
||||
});
|
||||
const report = {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
||||
};
|
||||
registry.register({
|
||||
id: "test-plan",
|
||||
label: "Test plan",
|
||||
discover: async (scope) =>
|
||||
scope.kind === "global" ||
|
||||
(scope.provider === "opencode" && scope.model === "openai/test-model")
|
||||
? [{ key: "account", input: {} }]
|
||||
: [],
|
||||
fetch: async () => report,
|
||||
});
|
||||
try {
|
||||
const host = await registry.listReports();
|
||||
expect(host.map((entry) => entry.report)).toEqual([report]);
|
||||
const agent = await registry.listReports({ agentId: session.id });
|
||||
expect(agent.map((entry) => entry.report)).toEqual([report]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await client.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
function terminalEvents(events: AgentStreamEvent[]) {
|
||||
return events.filter(
|
||||
|
||||
+2
-2
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
|
||||
import type { PiChildSessionFile, PiExtension } from "../contract.js";
|
||||
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
|
||||
|
||||
@@ -98,8 +99,7 @@ export const edxethPiSubagents: PiExtension = {
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
},
|
||||
subagents,
|
||||
childSessions,
|
||||
|
||||
-12
@@ -130,18 +130,6 @@ describe("@gotgenes/pi-subagents adapter", () => {
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "completed"]);
|
||||
expect(
|
||||
events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call_502323",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test("correlates a background notification and result collection", async () => {
|
||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||
|
||||
+3
-18
@@ -64,8 +64,7 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: title,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running")
|
||||
return {
|
||||
@@ -93,10 +92,6 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
|
||||
pendingFilesByAgent.delete(details.data.agentId);
|
||||
const childSessions = childSession(call.callId, file);
|
||||
const childStatus = status(details.data.status);
|
||||
// Without a session file, as on replay, the child's answer is the tool result.
|
||||
const answer =
|
||||
!file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
|
||||
return {
|
||||
detail,
|
||||
subagents: [
|
||||
@@ -106,17 +101,8 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
title,
|
||||
description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus,
|
||||
status: status(details.data.status),
|
||||
},
|
||||
...(answer
|
||||
? [
|
||||
{
|
||||
type: "timeline" as const,
|
||||
id: call.callId,
|
||||
item: { type: "assistant_message" as const, text: answer },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
childSessions,
|
||||
};
|
||||
@@ -132,8 +118,7 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
const detail = {
|
||||
type: "sub_agent" as const,
|
||||
description: details.success ? details.data.description : undefined,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running" || !details.success) return { detail };
|
||||
const file = details.data.transcriptPath;
|
||||
|
||||
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "scout",
|
||||
description: "Inspect files",
|
||||
log: "",
|
||||
log: "Found two files",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "reviewer",
|
||||
description: "Review the Pi mapper change",
|
||||
log: "",
|
||||
log: "The mapper change preserves provider status.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -131,8 +131,7 @@ export const piSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: args.data.agent,
|
||||
description: args.data.task,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
const base = {
|
||||
type: "upsert" as const,
|
||||
|
||||
@@ -54,6 +54,8 @@ export async function verifySubagentFixture(
|
||||
try {
|
||||
await session.startTurn("Delegate work");
|
||||
for (const event of fixture.events) pi.latestSession().emit(event);
|
||||
const immediate = live.filter((event) => event.type === "provider_subagent");
|
||||
expect(immediate.some((event) => event.event.type === "timeline")).toBe(false);
|
||||
pi.latestSession().finishTurn();
|
||||
const replay: AgentStreamEvent[] = [];
|
||||
for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event);
|
||||
|
||||
+2
-19
@@ -36,17 +36,12 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call-1",
|
||||
item: { type: "assistant_message", text: "Wrapped up (turn limit)" },
|
||||
},
|
||||
]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect",
|
||||
log: "",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
});
|
||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||
@@ -58,18 +53,6 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "completed"]);
|
||||
expect(
|
||||
events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call_458926",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test("uses a structured notification to complete a background child", async () => {
|
||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||
@@ -164,7 +147,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
description: undefined,
|
||||
log: "",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+3
-18
@@ -57,8 +57,7 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: title,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running")
|
||||
return {
|
||||
@@ -85,10 +84,6 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
const file = outputFileFromToolResult(call.result);
|
||||
const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : [];
|
||||
if (file) readSessions.add(file);
|
||||
const childStatus = status(details.data.status);
|
||||
// A foreground child has no session file; its answer is the tool result.
|
||||
const answer =
|
||||
!file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
|
||||
return {
|
||||
detail,
|
||||
subagents: [
|
||||
@@ -98,17 +93,8 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
title,
|
||||
description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus,
|
||||
status: status(details.data.status),
|
||||
},
|
||||
...(answer
|
||||
? [
|
||||
{
|
||||
type: "timeline" as const,
|
||||
id: call.callId,
|
||||
item: { type: "assistant_message" as const, text: answer },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
childSessions,
|
||||
};
|
||||
@@ -124,8 +110,7 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
const detail = {
|
||||
type: "sub_agent" as const,
|
||||
description: details.success ? details.data.description : undefined,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running" || !details.success) return { detail };
|
||||
return {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
||||
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
|
||||
|
||||
// Measure a cold fetch on its own thread, without Vitest reporting or callbacks.
|
||||
const wholeTurns = process.argv[2] === "true";
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Finish fixture allocation and collect its temporary garbage before measuring.
|
||||
globalThis.gc!();
|
||||
await nextTurn();
|
||||
const measured = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
+23
-1
@@ -1,3 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -7,7 +10,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("background children keep their actions after the parent finishes (#6253)", async () => {
|
||||
test("background cards retain child actions after the parent finishes (#6253)", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
@@ -59,6 +62,22 @@ test("background children keep their actions after the parent finishes (#6253)",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
],
|
||||
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
|
||||
);
|
||||
expect(stdout).toContain("[Bash] ls -1");
|
||||
expect(stdout).toContain("[Bash] pwd");
|
||||
expect(
|
||||
atFinish.entries.filter(
|
||||
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
||||
@@ -75,6 +94,9 @@ test("background children keep their actions after the parent finishes (#6253)",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const card of cards) {
|
||||
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import type { z } from "zod";
|
||||
@@ -72,18 +71,15 @@ class SubscriptionPeer {
|
||||
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
||||
this.socket.send(JSON.stringify({ type: "session", message }));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
this.frames.some(
|
||||
(frame) =>
|
||||
frame.type === "session" &&
|
||||
"payload" in frame.message &&
|
||||
"requestId" in frame.message.payload &&
|
||||
"requestId" in message &&
|
||||
frame.message.payload.requestId === message.requestId,
|
||||
),
|
||||
// Diagnostics invoke external tools with their own multi-second deadlines.
|
||||
{ timeout: 10_000 },
|
||||
.poll(() =>
|
||||
this.frames.some(
|
||||
(frame) =>
|
||||
frame.type === "session" &&
|
||||
"payload" in frame.message &&
|
||||
"requestId" in frame.message.payload &&
|
||||
"requestId" in message &&
|
||||
frame.message.payload.requestId === message.requestId,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -123,16 +119,6 @@ class SubscriptionPeer {
|
||||
}
|
||||
}
|
||||
|
||||
function removedAgentIds(peer: SubscriptionPeer): string[] {
|
||||
return peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" &&
|
||||
frame.message.type === "agent_update" &&
|
||||
frame.message.payload.kind === "remove"
|
||||
? [frame.message.payload.agentId]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const peers: SubscriptionPeer[] = [];
|
||||
@@ -239,96 +225,6 @@ test("combined filtered agent lists create independent server-owned observations
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
state: "live",
|
||||
prepare: async (_manager: AgentManager, _agentId: string) => {},
|
||||
},
|
||||
{
|
||||
state: "stored-only",
|
||||
prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId),
|
||||
},
|
||||
])(
|
||||
"$state agent deletion reaches directory observers across logical sessions without broadcasting its reply",
|
||||
async ({ prepare }) => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const admin = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.8.0",
|
||||
});
|
||||
const peers: SubscriptionPeer[] = [];
|
||||
try {
|
||||
await admin.connect();
|
||||
const workspace = (
|
||||
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
|
||||
).workspace;
|
||||
if (!workspace) throw new Error("Workspace creation failed");
|
||||
const removed = await admin.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
const survivor = await admin.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
const sentinel = path.join(daemon.staticDir, "keep-project-file.txt");
|
||||
await writeFile(sentinel, "keep this project file\n");
|
||||
await prepare(daemon.daemon.agentManager, removed.id);
|
||||
const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
||||
const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
||||
const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer");
|
||||
const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", {
|
||||
owned_subscriptions: false,
|
||||
});
|
||||
peers.push(requester, sibling, observer, legacy);
|
||||
for (const [index, peer] of peers.entries()) {
|
||||
await peer.request({
|
||||
type: "fetch_agents_request",
|
||||
requestId: `directory-${index}`,
|
||||
subscribe: {},
|
||||
});
|
||||
const entries = peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" && frame.message.type === "fetch_agents_response"
|
||||
? frame.message.payload.entries.map((entry) => entry.agent.id)
|
||||
: [],
|
||||
);
|
||||
expect(entries.sort()).toEqual([removed.id, survivor.id].sort());
|
||||
peer.frames.length = 0;
|
||||
}
|
||||
await requester.request({
|
||||
type: "delete_agent_request",
|
||||
requestId: "delete-observed-agent",
|
||||
agentId: removed.id,
|
||||
});
|
||||
await expect
|
||||
.poll(() => peers.map(removedAgentIds))
|
||||
.toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]);
|
||||
const replies = peers.map((peer) =>
|
||||
peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" && frame.message.type === "agent_deleted"
|
||||
? [frame.message.payload]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
expect(replies).toEqual([
|
||||
[{ agentId: removed.id, requestId: "delete-observed-agent" }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull();
|
||||
expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id);
|
||||
expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n");
|
||||
} finally {
|
||||
for (const peer of peers) peer.close();
|
||||
await admin.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace filters and identical queries coexist until their own release", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
@@ -783,8 +679,7 @@ test("same-workspace terminal directory observers do not share a teardown slot",
|
||||
peer.frames.length = 0;
|
||||
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
||||
workspaceId: workspace.id,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
command: "/bin/cat",
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
await expect
|
||||
@@ -833,8 +728,7 @@ test("terminal output has separate server IDs and binary slots, including in a s
|
||||
).workspace!;
|
||||
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
||||
workspaceId: workspace.id,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
command: "/bin/cat",
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
||||
@@ -1529,7 +1423,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
});
|
||||
const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||
const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await admin.connect();
|
||||
await requester.request({
|
||||
@@ -1568,7 +1462,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
}),
|
||||
);
|
||||
const agent = await admin.createAgent({
|
||||
provider: "mock",
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
title: "Attention",
|
||||
});
|
||||
|
||||
@@ -1075,29 +1075,23 @@ export default function contribute(plugin: unknown) {
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await service.installDirectory({ path: first });
|
||||
await service.installDirectory({ path: second });
|
||||
expect(catalogIds(service)).toEqual(["first", "second"]);
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
await service.start();
|
||||
await service.installDirectory({ path: first });
|
||||
await service.installDirectory({ path: second });
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
||||
"Plugin is not available",
|
||||
);
|
||||
await store.patch({ pluginsEnabled: true });
|
||||
// Observe recovery itself. Reloading here restarts a healthy plugin and
|
||||
// would also conceal a failure to restart it after the global enable.
|
||||
await expect
|
||||
.poll(() => service.listPlugins(), { timeout: 10_000 })
|
||||
.toMatchObject([
|
||||
{ id: "first", status: "running" },
|
||||
{ id: "second", status: "running" },
|
||||
]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
}
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
||||
"Plugin is not available",
|
||||
);
|
||||
await store.patch({ pluginsEnabled: true });
|
||||
await service.reloadPlugin("first");
|
||||
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
|
||||
{ id: "first", status: "running" },
|
||||
{ id: "second", status: "running" },
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("does not publish an in-flight start after a later global disable", async () => {
|
||||
|
||||
@@ -1992,24 +1992,6 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_removed") {
|
||||
if (event.internal) return;
|
||||
void this.agentUpdates
|
||||
.removeAgent(event.agentId)
|
||||
.then(() => {
|
||||
if (event.workspaceId)
|
||||
return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: event.agentId },
|
||||
"Failed to emit agent removal",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_state") {
|
||||
this.sessionLogger.trace(
|
||||
{
|
||||
@@ -3355,8 +3337,10 @@ export class Session {
|
||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||
|
||||
const knownAgent =
|
||||
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
||||
const knownWorkspaceId =
|
||||
this.agentManager.getAgent(agentId)?.workspaceId ??
|
||||
(await this.agentStorage.get(agentId))?.workspaceId ??
|
||||
null;
|
||||
|
||||
// File-backed storage still needs an early delete fence before closeAgent().
|
||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||
@@ -3377,8 +3361,6 @@ export class Session {
|
||||
try {
|
||||
await this.agentStorage.remove(agentId);
|
||||
await this.agentManager.deleteAgentState(agentId);
|
||||
// Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply.
|
||||
if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
||||
}
|
||||
@@ -3390,6 +3372,12 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.agentUpdates.removeAgent(agentId);
|
||||
|
||||
if (knownWorkspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { build } from "esbuild";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
readPaseoConfig,
|
||||
@@ -21,40 +20,21 @@ import {
|
||||
writePaseoConfigForEdit,
|
||||
} from "./paseo-config-file.js";
|
||||
|
||||
let probeDirectory: string;
|
||||
let probePath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-"));
|
||||
probePath = join(probeDirectory, "config-responsiveness.mjs");
|
||||
// Compile before starting the measured process. A runtime TypeScript loader
|
||||
// owns MessagePorts that the responsiveness probe otherwise charges as work.
|
||||
await build({
|
||||
entryPoints: [
|
||||
fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)),
|
||||
],
|
||||
outfile: probePath,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(probeDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function measureConfigRead(directory: string) {
|
||||
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--import", "tsx", fixture, directory],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
blockingMs: number;
|
||||
filesystemRequests: number;
|
||||
yields: number;
|
||||
workerMessages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +84,7 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes test-runner and loader activity from the config read budget", async () => {
|
||||
it("excludes unrelated test-runner CPU from the config read budget", async () => {
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const unrelatedWork = new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
@@ -120,10 +100,8 @@ describe("paseo config file substrate", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
await unrelatedWork;
|
||||
expect(workerMessages).toBe(0);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
@@ -141,13 +119,11 @@ describe("paseo config file substrate", () => {
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
const { result, blockingMs, filesystemRequests, yields, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
|
||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(yields).toBeGreaterThan(0);
|
||||
expect(workerMessages).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
||||
@@ -155,11 +131,9 @@ describe("paseo config file substrate", () => {
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(workerMessages).toBe(0);
|
||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { createHook, executionAsyncResource } from "node:async_hooks";
|
||||
import { MessagePort } from "node:worker_threads";
|
||||
import assert from "node:assert/strict";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
||||
|
||||
// The parent bundles this fixture before launching plain Node, keeping test
|
||||
// runner callbacks and runtime loader messages outside the measured thread.
|
||||
// Read the real file once, without warming the config snapshot or schema.
|
||||
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
|
||||
// thread. Read the real file once, without warming the config snapshot or schema.
|
||||
const directory = process.argv[2];
|
||||
assert.ok(directory, "Config directory is required");
|
||||
await nextTurn();
|
||||
let workerMessages = 0;
|
||||
const activity = createHook({
|
||||
before() {
|
||||
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
|
||||
},
|
||||
}).enable();
|
||||
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
||||
readPaseoConfigForEdit(directory),
|
||||
);
|
||||
activity.disable();
|
||||
process.stdout.write(JSON.stringify({ ...measured, workerMessages }));
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
|
||||
@@ -486,74 +486,3 @@ diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles
|
||||
...(narrowedProps.style ? {
|
||||
style: styleSecrets.styles
|
||||
} : {}),
|
||||
diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
||||
index 305fa49..5957aa5 100644
|
||||
--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
||||
+++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
|
||||
@@ -35,10 +35,17 @@ class UnistylesRuntime {
|
||||
return this.darkMedia;
|
||||
}
|
||||
get colorScheme() {
|
||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
||||
+ // of any list whose `matches` was read before the document became visible again, which
|
||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
||||
+ if ((0, _utils.isServer)()) {
|
||||
+ return _types.ColorScheme.Unspecified;
|
||||
+ }
|
||||
switch (true) {
|
||||
- case this.getLightMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
||||
return _types.ColorScheme.Light;
|
||||
- case this.getDarkMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
||||
return _types.ColorScheme.Dark;
|
||||
default:
|
||||
return _types.ColorScheme.Unspecified;
|
||||
diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
||||
index 7f13a06..c487bee 100644
|
||||
--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
||||
+++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js
|
||||
@@ -31,10 +31,17 @@ export class UnistylesRuntime {
|
||||
return this.darkMedia;
|
||||
}
|
||||
get colorScheme() {
|
||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
||||
+ // of any list whose `matches` was read before the document became visible again, which
|
||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
||||
+ if (isServer()) {
|
||||
+ return ColorScheme.Unspecified;
|
||||
+ }
|
||||
switch (true) {
|
||||
- case this.getLightMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
||||
return ColorScheme.Light;
|
||||
- case this.getDarkMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
||||
return ColorScheme.Dark;
|
||||
default:
|
||||
return ColorScheme.Unspecified;
|
||||
diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts
|
||||
index a16dac0..6519858 100644
|
||||
--- a/node_modules/react-native-unistyles/src/web/runtime.ts
|
||||
+++ b/node_modules/react-native-unistyles/src/web/runtime.ts
|
||||
@@ -41,8 +41,16 @@ export class UnistylesRuntime {
|
||||
get colorScheme() {
|
||||
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
|
||||
+ // MediaQueryList change events while the document is hidden and drops the pending event
|
||||
+ // of any list whose `matches` was read before the document became visible again, which
|
||||
+ // would silence the adaptive-theme listeners registered on those two lists.
|
||||
+ if (isServer()) {
|
||||
+ return ColorScheme.Unspecified
|
||||
+ }
|
||||
+
|
||||
switch (true) {
|
||||
- case this.getLightMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
|
||||
return ColorScheme.Light
|
||||
- case this.getDarkMedia()?.matches:
|
||||
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
|
||||
return ColorScheme.Dark
|
||||
default:
|
||||
return ColorScheme.Unspecified
|
||||
|
||||
@@ -24,7 +24,6 @@ const patchedPackages = [
|
||||
},
|
||||
// Registry cleanup: remove after react-native-unistyles ships
|
||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
||||
// Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes.
|
||||
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-unistyles",
|
||||
|
||||
@@ -9,7 +9,7 @@ argument-hint: "[--profile <name>] <question or topic>"
|
||||
|
||||
Single agent. Reads the situation you're in. Gives a judgment. You decide what to do — the advisor doesn't drive the work.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. Optional syntax: `[--profile <name>] <question or topic>`.
|
||||
**User's request:** $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -40,15 +40,13 @@ This is analysis only. Do NOT edit, create, or delete any files. Do NOT write co
|
||||
|
||||
## Forwarded skills
|
||||
|
||||
If the user's request contains another skill reference, such as `/unslop`, `/skill:unslop`, or `$unslop`, the user is asking the advisor to run that skill against the current task. Examples:
|
||||
If `$ARGUMENTS` contains another skill reference — `/unslop`, `/unslop-risk`, `$unslop`, etc. — the user is asking the advisor to run that skill against the current task. Examples:
|
||||
|
||||
- `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff.
|
||||
- `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review.
|
||||
- `/paseo-advisor $diagnose this build failure` → advisor invokes `/diagnose`.
|
||||
- `/skill:paseo-advisor /skill:unslop` → advisor runs the `unslop` skill on the current diff in Pi.
|
||||
- `$paseo-advisor $unslop` → advisor runs the `unslop` skill on the current diff in Codex.
|
||||
|
||||
Parse the forwarded skill name from the user's request (`/<name>`, `/skill:<name>`, or `$<name>`), excluding this skill's own invocation. In the briefing, tell the advisor explicitly:
|
||||
Parse the forwarded skill name out of `$ARGUMENTS` (`/<name>` or `$<name>`). In the briefing, tell the advisor explicitly:
|
||||
|
||||
```
|
||||
Invoke the `<name>` skill against this task. Load it via the Skill tool before doing anything else.
|
||||
|
||||
@@ -8,7 +8,7 @@ user-invocable: true
|
||||
|
||||
Two agents from contrasting profiles, fresh context, planning a solution in parallel.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's additional context:** $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ user-invocable: true
|
||||
|
||||
Transfer the current task — context, decisions, failed attempts, constraints — to a fresh agent. The receiving agent starts with **zero context**, so the handoff prompt must be a self-contained briefing.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's arguments:** $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Answer questions about the Paseo product and app, including setup,
|
||||
|
||||
You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them.
|
||||
|
||||
Use the user's question and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's question:** $ARGUMENTS
|
||||
|
||||
## Use current documentation
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Build and manage trusted local Paseo plugins. Use when the user ask
|
||||
|
||||
Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's request:** $ARGUMENTS
|
||||
|
||||
## Check current documentation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user