Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 82ba1a7575 test(codex): reproduce finished legacy sub-agent replaying as running (#4538)
Failing on purpose. A thread whose child was spawned with the legacy
spawnAgent/wait/closeAgent items replays the child as running, because
only the spawn snapshot is read on replay.
2026-09-29 05:05:57 +02:00
16 changed files with 107 additions and 238 deletions
@@ -17,14 +17,12 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -268,21 +266,3 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -3,7 +3,6 @@ import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { loadSessionMessageReaders } from "./new-workspace";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
@@ -230,22 +229,3 @@ async function requireBoundingBox(
}
return box;
}
export async function observePromptIndexRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const agentIds: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "agent.timeline.list_prompts.request") {
agentIds.push(request.agentId);
}
});
});
return {
async waitForRequestFor(agentId: string) {
await expect.poll(() => agentIds).toContain(agentId);
},
requestedAgentIds: () => [...new Set(agentIds)],
};
}
@@ -206,12 +206,6 @@ export async function createCreationScenario(page: Page) {
)
.toBe(count);
},
async agentIds() {
await requests.settled();
return (await project.client.fetchAgents()).entries
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -12,6 +12,7 @@ describe("chat outline prompt index epoch", () => {
it("accepts only the authoritative timeline epoch", () => {
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
});
});
@@ -2,8 +2,11 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
return timelineEpoch === indexEpoch;
export function shouldAcceptPromptIndexEpoch(
timelineEpoch: string | null,
indexEpoch: string,
): boolean {
return timelineEpoch === null || timelineEpoch === indexEpoch;
}
/**
@@ -37,31 +37,6 @@ describe("useChatOutline", () => {
runtime.subscribeAgentTimeline.mockClear();
});
it("waits for a served timeline before requesting its prompt index", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
const viewportRef = createRef<StreamViewportHandle>();
const { rerender } = renderHook(
({ agentId, timelineEpoch }) =>
useChatOutline({
agentId,
serverId: "server-1",
timelineEpoch,
tail: [],
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
}),
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
);
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
});
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
const first = deferred<{ epoch: string; prompts: [] }>();
const second = deferred<{
@@ -77,10 +77,8 @@ export function useChatOutline({
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
// epoch, and its id names no agent the daemon knows.
useEffect(() => {
if (!isWeb || !enabled || timelineEpoch === null) {
if (!isWeb || !enabled) {
setIndex(null);
return;
}
@@ -58,23 +58,10 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
try {
await client.connect();
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
activeTurnBehavior: "interrupt",
});
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
} finally {
await client.close();
}
}
-1
View File
@@ -425,7 +425,6 @@ export interface DaemonClientTrace {
export interface SendMessageOptions {
messageId?: string;
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
activeTurnBehavior?: ActiveTurnBehavior;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
+1 -7
View File
@@ -744,17 +744,13 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", {
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(sendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "hello",
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
ws.message(
@@ -772,7 +768,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -781,7 +776,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
});
ws.message(
sessionMessage({
+6 -2
View File
@@ -24,6 +24,7 @@ import type {
ProjectPlacementPayload,
WorkspaceProjectDescriptorPayload,
RefreshProvidersSnapshotResponseMessage,
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
@@ -54,7 +55,6 @@ import type {
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
SendMessageOptions,
WaitForFinishResult,
} from "./daemon-client.js";
@@ -276,7 +276,11 @@ export interface PaseoAgentTimelineRefetchOptions {
requestId?: string;
}
export type PaseoAgentSendOptions = SendMessageOptions;
export interface PaseoAgentSendOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
}
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
@@ -1117,42 +1117,6 @@ describe("Codex app-server provider", () => {
});
});
test("starts a session without a model on the project's Codex model and effort", async () => {
const cwd = "/workspace/project";
const appServer = createFakeCodexAppServer({
"config/read": (params) => ({
config:
(params as { cwd?: unknown }).cwd === cwd
? { model: "project-model", model_reasoning_effort: "high" }
: { model: "global-model", model_reasoning_effort: "low" },
}),
getUserSavedConfig: () => ({
config: { model: "global-model", modelReasoningEffort: "low" },
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd, model: undefined }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.startTurn("use project defaults");
await expect(appServer.waitForRequest("thread/start")).resolves.toMatchObject({
model: "project-model",
});
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "project-model",
effort: "high",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preserves cwd-resolved Codex writable roots under an explicit workflow mode", async () => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
@@ -4360,6 +4324,69 @@ describe("Codex app-server provider", () => {
});
});
test("replays a legacy sub-agent that later finished as completed", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
if ((params as { threadId?: string }).threadId !== "test-thread") {
return { thread: { turns: [] } };
}
return {
thread: {
turns: [
{
items: [
{
type: "collabAgentToolCall",
id: "exec-spawn-history",
tool: "spawnAgent",
status: "completed",
prompt: "Research child",
receiverThreadIds: ["finished-child-thread"],
agentsStates: { "finished-child-thread": { status: "pendingInit" } },
},
{
type: "collabAgentToolCall",
id: "exec-wait-history",
tool: "wait",
status: "completed",
receiverThreadIds: ["finished-child-thread"],
agentsStates: {
"finished-child-thread": { status: "completed", message: "done" },
},
},
{
type: "collabAgentToolCall",
id: "exec-close-history",
tool: "closeAgent",
status: "completed",
receiverThreadIds: ["finished-child-thread"],
agentsStates: { "finished-child-thread": { status: "completed" } },
},
],
},
],
},
};
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
),
).toMatchObject([{ type: "upsert", id: "finished-child-thread", status: "completed" }]);
});
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
const session = createSession();
session.client = {
@@ -3100,32 +3100,10 @@ const CodexNotificationSchema = z.union([
),
]);
// config/read resolves the effective layered config, including a trusted project's
// .codex/config.toml when given its cwd; getUserSavedConfig predates it.
async function readCodexConfiguredDefaults(
client: CodexAppServerClient,
logger: Logger,
cwd: string | null,
): Promise<CodexConfiguredDefaults> {
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", { cwd }));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
if (configReadDefaults.model && configReadDefaults.thinkingOptionId) {
return configReadDefaults;
}
let savedConfigDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
@@ -3141,7 +3119,26 @@ async function readCodexConfiguredDefaults(
logger.debug({ error }, "Failed to read Codex saved config defaults");
}
return mergeCodexConfiguredDefaults(configReadDefaults, savedConfigDefaults);
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
return savedConfigDefaults;
}
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
}
interface CodexSkillPromptBlock {
@@ -5122,11 +5119,7 @@ export class CodexAppServerAgentSession implements AgentSession {
let model = this.config.model;
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (!model || !thinkingOptionId) {
configuredDefaults = await readCodexConfiguredDefaults(
this.client,
this.logger,
this.config.cwd ?? null,
);
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
}
if (!model) {
model = configuredDefaults.model;
@@ -7330,7 +7323,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
readCodexConfiguredDefaults(client!, this.logger, null),
readCodexConfiguredDefaults(client!, this.logger),
);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
@@ -355,8 +355,7 @@ test.each([
await resumed?.close();
await original?.close();
await client.shutdown();
// OpenCode can still hold its ripgrep download open on Windows right after shutdown.
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
await rm(root, { recursive: true, force: true });
}
},
240_000,
@@ -221,15 +221,6 @@ function deriveOpencodeTaskDetail(
};
}
function toOpencodeEditInput(value: { filePath: string; oldString?: string; newString?: string }) {
return {
filePath: value.filePath,
oldString: nonEmptyString(value.oldString),
newString: nonEmptyString(value.newString),
unifiedDiff: undefined,
};
}
const OpencodeEditInputSchema = z.union([
z
.object({
@@ -238,12 +229,12 @@ const OpencodeEditInputSchema = z.union([
newString: z.string().optional(),
})
.passthrough()
.transform(toOpencodeEditInput),
// OpenCode v2's edit tool names the file `path`.
z
.object({ path: z.string(), oldString: z.string(), newString: z.string() })
.passthrough()
.transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })),
.transform((value) => ({
filePath: value.filePath,
oldString: nonEmptyString(value.oldString),
newString: nonEmptyString(value.newString),
unifiedDiff: undefined,
})),
ToolEditInputSchema,
]);
@@ -1,56 +0,0 @@
import type { SessionMessageAssistant } from "@opencode/client";
import { expect, test } from "vitest";
import { V2Timeline } from "./timeline.js";
function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content,
};
}
test("a completed edit carries the replaced and replacement text", () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
type: "tool",
id: "call-edit",
name: "edit",
executed: false,
state: {
status: "completed",
input: { path: "a.txt", oldString: "beta", newString: "BETA" },
content: [{ type: "text", text: "Edited a.txt (1 replacement)" }],
metadata: {
files: [
{
file: "a.txt",
patch:
"Index: a.txt\n===================================================================\n--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
status: "modified",
additions: 1,
deletions: 1,
},
],
truncated: false,
},
},
time: { created: 2, ran: 2, completed: 2 },
},
]);
expect(new V2Timeline().messages([edit])).toMatchObject([
{
item: {
type: "tool_call",
name: "edit",
status: "completed",
detail: { type: "edit", filePath: "a.txt", oldString: "beta", newString: "BETA" },
},
},
]);
});