Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/app/src/types/stream-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ describe("applyStreamEvent", () => {
expect(result.tail[0].kind).toBe("assistant_message");
});

it("replaces stale tail content with finalized head content on turn completion", () => {
const result = applyStreamEvent({
tail: [
{
kind: "assistant_message",
id: "assistant-shared",
text: "Hello",
timestamp: new Date(0),
},
],
head: [
{
kind: "assistant_message",
id: "assistant-shared",
text: "Hello world",
timestamp: new Date(1),
},
],
event: completionEvent(),
timestamp: baseTimestamp,
});

expect(result.head).toHaveLength(0);
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({
kind: "assistant_message",
id: "assistant-shared",
text: "Hello world",
});
});

it("flushes reasoning when assistant message starts", () => {
let result = applyStreamEvent({
tail: [],
Expand Down
26 changes: 21 additions & 5 deletions packages/app/src/types/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,14 +735,30 @@ function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
}

const finalized = finalizeHeadItems(head);
const tailIds = new Set(tail.map((item) => item.id));
const newItems = finalized.filter((item) => !tailIds.has(item.id));
const tailIndexById = new Map(tail.map((item, index) => [item.id, index]));
let nextTail = tail;

if (newItems.length === 0) {
return tail;
for (const item of finalized) {
const existingIndex = tailIndexById.get(item.id);
if (existingIndex === undefined) {
if (nextTail === tail) {
nextTail = [...tail];
}
nextTail.push(item);
tailIndexById.set(item.id, nextTail.length - 1);
continue;
}

const existing = nextTail[existingIndex];
if (existing !== item) {
if (nextTail === tail) {
nextTail = [...tail];
}
nextTail[existingIndex] = item;
}
}

return [...tail, ...newItems];
return nextTail;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { existsSync, rmSync } from "node:fs";

import type { AgentLaunchContext } from "../agent-sdk-types.js";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
Expand All @@ -10,6 +10,32 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";

const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const CODEX_PROVIDER = "codex";

function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: CODEX_PROVIDER,
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
...overrides,
};
}

function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig(configOverrides),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as AgentSession & { [key: string]: unknown };
session.connected = true;
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
}

describe("Codex app-server provider", () => {
const logger = createTestLogger();
Expand Down Expand Up @@ -55,6 +81,25 @@ describe("Codex app-server provider", () => {
}
});

test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});

expect(item).toEqual({
type: "tool_call",
callId: "plan-turn-1",
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout\n- Add validation",
},
});
});

test("maps patch notifications with object-style single change payloads", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
callId: "patch-object-single",
Expand Down Expand Up @@ -119,4 +164,218 @@ describe("Codex app-server provider", () => {
expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
});

test("projects request_user_input into a question permission and running timeline tool call", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));

void (session as any).handleToolApprovalRequest({
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
});

expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-1",
name: "request_user_input",
status: "running",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
},
},
{
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: {
id: "permission-call-question-1",
provider: "codex",
name: "request_user_input",
kind: "question",
title: "Question",
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
input: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
metadata: {
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
},
},
]);
});

test("maps question responses from headers back to question ids and completes the tool call", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));

const pendingResponse = (session as any).handleToolApprovalRequest({
itemId: "call-question-2",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
});

await session.respondToPermission("permission-call-question-2", {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
});

await expect(pendingResponse).resolves.toEqual({
answers: {
favorite_drink: { answers: ["Tea"] },
},
});
expect(events.at(-2)).toEqual({
type: "permission_resolved",
provider: "codex",
turnId: "test-turn",
requestId: "permission-call-question-2",
resolution: {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
},
});
expect(events.at(-1)).toEqual({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-2",
name: "request_user_input",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
answers: {
favorite_drink: ["Tea"],
},
},
},
});
});

test("emits buffered assistant text before task_complete closes the turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));

;(session as any).handleNotification("item/agentMessage/delta", {
itemId: "msg-late-final",
delta: "COMPLEX_REPRO_OK",
});

;(session as any).handleNotification("codex/event/task_complete", {
msg: { type: "task_complete" },
});

expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "assistant_message",
text: "COMPLEX_REPRO_OK",
},
},
{
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: undefined,
},
]);
});
});
Loading
Loading