mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1b4e7898 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 |
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
removeCurrentHostFromSettings,
|
||||
scrollSettingsSidebarToEnd,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
@@ -77,6 +79,23 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test.describe("in a short window", () => {
|
||||
test.use({ viewport: { width: 1280, height: 480 } });
|
||||
|
||||
test("selecting a section keeps the sidebar scroll position", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await openSettingsHostSection(page, serverId, "plugins");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
|
||||
await openSettingsHostSection(page, serverId, "terminals");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
|
||||
@@ -453,3 +453,21 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
|
||||
await expect(scrollBody).toBeVisible();
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
const readOffset = () => scrollBody.evaluate((node) => node.scrollTop);
|
||||
await expect
|
||||
.poll(readOffset, { message: "settings sidebar must overflow for this check" })
|
||||
.toBeGreaterThan(0);
|
||||
await expect.poll(async () => (await readOffset()) - (await readOffset())).toBe(0);
|
||||
return readOffset();
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
@@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js";
|
||||
const agent = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "running",
|
||||
archivedAt: null,
|
||||
archivedAt: null as string | null,
|
||||
cwd: "/tmp/project",
|
||||
};
|
||||
const archivedAgent = {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/project/nested",
|
||||
};
|
||||
const archivedOutsideAgent = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
status: "closed",
|
||||
archivedAt: "2026-10-01T00:00:00.000Z",
|
||||
cwd: "/tmp/elsewhere",
|
||||
};
|
||||
let daemonAgents: (typeof agent)[] = [agent];
|
||||
const toEntry = (entry: typeof agent) => ({ agent: entry });
|
||||
// The fake daemon pages history two agents at a time.
|
||||
const historyPage = (cursor: string | undefined) => {
|
||||
const start = Number(cursor ?? 0);
|
||||
const end = start + 2;
|
||||
return {
|
||||
entries: daemonAgents.slice(start, end).map(toEntry),
|
||||
pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null },
|
||||
};
|
||||
};
|
||||
const cancelAgent = vi.fn(async () => {
|
||||
throw new Error("active run cancellation was not acknowledged");
|
||||
});
|
||||
const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) =>
|
||||
historyPage(options.page.cursor),
|
||||
);
|
||||
const deleteAgent = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
|
||||
fetchAgents: vi.fn(async () => historyPage(undefined)),
|
||||
fetchAgentHistory,
|
||||
fetchAgent: vi.fn(async () => ({ agent })),
|
||||
cancelAgent,
|
||||
deleteAgent,
|
||||
@@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({
|
||||
}));
|
||||
|
||||
describe("runDeleteCommand", () => {
|
||||
beforeEach(() => {
|
||||
daemonAgents = [agent];
|
||||
deleteAgent.mockClear();
|
||||
fetchAgentHistory.mockClear();
|
||||
});
|
||||
|
||||
it("force-deletes a running agent when graceful cancellation is refused", async () => {
|
||||
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
|
||||
|
||||
expect(fetchAgentHistory).not.toHaveBeenCalled();
|
||||
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(result.data).toEqual({
|
||||
@@ -38,4 +72,30 @@ describe("runDeleteCommand", () => {
|
||||
agentIds: [agent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents on every history page with --all", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 3,
|
||||
agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes archived agents inside the directory with --cwd", async () => {
|
||||
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
|
||||
|
||||
const result = await runDeleteCommand(
|
||||
undefined,
|
||||
{ daemonTarget, cwd: "/tmp/project" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.data).toEqual({
|
||||
deletedCount: 2,
|
||||
agentIds: [agent.id, archivedAgent.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions {
|
||||
|
||||
export type AgentDeleteResult = SingleResult<DeleteResult>;
|
||||
|
||||
// History includes archived agents and agents whose provider is unavailable.
|
||||
async function fetchHistoryAgents(client: DaemonClient) {
|
||||
const agents: FetchAgentHistoryEntry["agent"][] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchAgentHistory({
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
agents.push(...payload.entries.map((entry) => entry.agent));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export async function runDeleteCommand(
|
||||
id: string | undefined,
|
||||
options: AgentDeleteOptions,
|
||||
@@ -50,19 +65,16 @@ export async function runDeleteCommand(
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const deletedIds: string[] = [];
|
||||
let agents: FetchAgentHistoryEntry["agent"][];
|
||||
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (options.all || options.cwd) {
|
||||
agents = await fetchHistoryAgents(client);
|
||||
if (!options.all) {
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
} else {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id! });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function claudeNotification(notificationType: string) {
|
||||
return JSON.stringify({
|
||||
session_id: "session-1",
|
||||
hook_event_name: "Notification",
|
||||
message: "Claude is waiting for your input",
|
||||
notification_type: notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
function ttyInput() {
|
||||
return {
|
||||
isTTY: true,
|
||||
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom('{"reason":"idle_prompt"}'),
|
||||
inputFrom(claudeNotification("idle_prompt")),
|
||||
);
|
||||
|
||||
expectPostedState(send, "needs-input");
|
||||
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
|
||||
|
||||
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
|
||||
"ignores Claude %s notifications",
|
||||
async (reason) => {
|
||||
async (notificationType) => {
|
||||
const send = await runHook(
|
||||
claudeProvider.id,
|
||||
claudeProvider.events[4].event,
|
||||
inputFrom(JSON.stringify({ reason })),
|
||||
inputFrom(claudeNotification(notificationType)),
|
||||
);
|
||||
|
||||
expect(send.calls).toEqual([]);
|
||||
|
||||
+1
-123
@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
|
||||
@@ -171,62 +171,6 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function startHeldFirstResponseServer(): Promise<{
|
||||
url: string;
|
||||
requestBodies: string[];
|
||||
releaseFirstResponse: () => void;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const requestBodies: string[] = [];
|
||||
let heldResponse: ServerResponse | null = null;
|
||||
const reply = (res: ServerResponse) => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.end(assistantMessageSse("done"));
|
||||
};
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
requestBodies.push(Buffer.concat(chunks).toString("utf8"));
|
||||
if (requestBodies.length === 1) {
|
||||
heldResponse = res;
|
||||
} else {
|
||||
reply(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected TCP address for mock responses server");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requestBodies,
|
||||
releaseFirstResponse: () => {
|
||||
if (!heldResponse) throw new Error("No held response to release");
|
||||
reply(heldResponse);
|
||||
heldResponse = null;
|
||||
},
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.closeAllConnections();
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
@@ -273,72 +217,6 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (local e2e)", () => {
|
||||
test.runIf(isCodexInstalled())(
|
||||
"rewinding a steer message keeps the earlier prompt of its turn in model context",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-cwd-"));
|
||||
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-home-"));
|
||||
const mockServer = await startHeldFirstResponseServer();
|
||||
|
||||
try {
|
||||
writeMockCodexConfig(codexHome, mockServer.url);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const session = await client.createSession(
|
||||
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
|
||||
{ env: { CODEX_HOME: codexHome } },
|
||||
);
|
||||
|
||||
try {
|
||||
const userMessageIds = new Map<string, string>();
|
||||
session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "user_message" &&
|
||||
event.item.messageId
|
||||
) {
|
||||
userMessageIds.set(event.item.text, event.item.messageId);
|
||||
}
|
||||
});
|
||||
const turnFinished = waitForEvent({
|
||||
session,
|
||||
timeoutMs: 15_000,
|
||||
label: "turn completion",
|
||||
predicate: (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||
event.type === "turn_completed",
|
||||
});
|
||||
|
||||
const started = await session.startTurn("ORIGINAL_PROMPT");
|
||||
await vi.waitFor(() => expect(mockServer.requestBodies).toHaveLength(1), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
session.steerActiveTurn!("STEER_PROMPT", { expectedTurnId: started.turnId }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
mockServer.releaseFirstResponse();
|
||||
await turnFinished;
|
||||
|
||||
await vi.waitFor(() => expect(userMessageIds.get("STEER_PROMPT")).toBeDefined(), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
session.revertConversation!({ messageId: userMessageIds.get("STEER_PROMPT")! }),
|
||||
).rejects.toThrow("Select the first message in the turn instead");
|
||||
|
||||
await session.run("AFTER_REWIND");
|
||||
expect(mockServer.requestBodies.at(-1)).toContain("AFTER_REWIND");
|
||||
expect(mockServer.requestBodies.at(-1)).toContain("ORIGINAL_PROMPT");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
await mockServer.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"reloads a persisted idle thread repeatedly without overlapping writers",
|
||||
async () => {
|
||||
|
||||
@@ -105,7 +105,10 @@ interface CollaborationModeRecord {
|
||||
}
|
||||
|
||||
interface CodexSessionTestAccess {
|
||||
codexUserMessages(): { messageId: string; turnId: string | null }[];
|
||||
codexUserMessageTurns(): {
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||
count(): number;
|
||||
};
|
||||
ensureThreadLoaded(): Promise<void>;
|
||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||
handleNotification(method: string, params: unknown): void;
|
||||
@@ -2200,60 +2203,6 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.each(["legacy", "paginated"] as const)(
|
||||
"rejects rewinding a steer message in a %s thread before forking",
|
||||
async (historyMode) => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": () => ({ thread: { id: "thread-1", historyMode, turns: [] } }),
|
||||
"turn/steer": () => ({ turnId: "turn-first" }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
try {
|
||||
const started = await session.startTurn("original prompt");
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-first" });
|
||||
const originalMessage = waitForNextTimelineItem(session, "user_message");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-first",
|
||||
text: "original prompt",
|
||||
turnId: "turn-first",
|
||||
});
|
||||
await originalMessage;
|
||||
await expect(
|
||||
session.steerActiveTurn("steer prompt", { expectedTurnId: started.turnId }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
const steerMessage = waitForNextTimelineItem(session, "user_message");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-steer",
|
||||
text: "steer prompt",
|
||||
turnId: "turn-first",
|
||||
});
|
||||
await steerMessage;
|
||||
appServer.completeTurn();
|
||||
|
||||
await expect(session.revertConversation({ messageId: "codex-steer" })).rejects.toThrow(
|
||||
"Select the first message in the turn instead",
|
||||
);
|
||||
expect(appServer.requests().filter((request) => request.method === "thread/fork")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(appServer.recordedRollbacks).toEqual([]);
|
||||
expect(session.id).toBe("thread-1");
|
||||
|
||||
await session.revertConversation({ messageId: "codex-first" });
|
||||
expect(session.id).toBe("forked-thread");
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["legacy", "paginated"] as const)(
|
||||
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
|
||||
async (historyMode) => {
|
||||
@@ -4689,9 +4638,10 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
await asInternals(session).loadPersistedHistory(session.client);
|
||||
|
||||
expect(asInternals(session).codexUserMessages()).toEqual([
|
||||
{ messageId: "message-history", turnId: "native-turn-1" },
|
||||
]);
|
||||
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
|
||||
index: 0,
|
||||
turnId: "native-turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ import {
|
||||
type CodexThreadRollbackResponse,
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessage, revertCodexConversation } from "./codex/rewind.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
@@ -4529,11 +4529,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private codexUserMessages(): CodexUserMessage[] {
|
||||
return this.userMessageTurnIds.map((messageId) => ({
|
||||
messageId,
|
||||
turnId: this.userMessageProviderTurnIds.get(messageId) ?? null,
|
||||
}));
|
||||
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
||||
return {
|
||||
resolve: (messageId) => {
|
||||
const index = this.userMessageTurnIndexes.get(messageId);
|
||||
return index === undefined
|
||||
? null
|
||||
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
|
||||
},
|
||||
count: () => this.userMessageTurnIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
@@ -4950,7 +4955,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
model: this.config.model ?? null,
|
||||
serviceTier: this.serviceTier,
|
||||
config: this.buildCodexInnerConfig(),
|
||||
userMessages: this.codexUserMessages(),
|
||||
userMessageTurns: this.codexUserMessageTurns(),
|
||||
threadRollbackAvailable: this.threadRollbackAvailable,
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
CodexThreadRollbackParams,
|
||||
CodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import { type CodexRewindClient, revertCodexConversation } from "./rewind.js";
|
||||
import {
|
||||
type CodexUserMessageTurnIndex,
|
||||
type CodexRewindClient,
|
||||
revertCodexConversation,
|
||||
} from "./rewind.js";
|
||||
|
||||
class FakeCodex implements CodexRewindClient {
|
||||
readonly recordedForks: CodexThreadForkParams[] = [];
|
||||
@@ -55,13 +59,33 @@ class FakeCodex implements CodexRewindClient {
|
||||
}
|
||||
}
|
||||
|
||||
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
||||
constructor(
|
||||
private readonly indexesByMessageId: Map<string, number>,
|
||||
private readonly turnIdsByMessageId: Map<string, string> = new Map(),
|
||||
) {}
|
||||
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null {
|
||||
const index = this.indexesByMessageId.get(messageId);
|
||||
return index === undefined
|
||||
? null
|
||||
: { index, turnId: this.turnIdsByMessageId.get(messageId) ?? null };
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.indexesByMessageId.size;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Codex Rewind", () => {
|
||||
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessages = [
|
||||
{ messageId: "codex-first", turnId: null },
|
||||
{ messageId: "codex-second", turnId: null },
|
||||
];
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
@@ -71,7 +95,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessages,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
@@ -94,18 +118,20 @@ describe("Codex Rewind", () => {
|
||||
|
||||
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessages = [
|
||||
{ messageId: "codex-first", turnId: null },
|
||||
{ messageId: "codex-second", turnId: null },
|
||||
{ messageId: "codex-third", turnId: null },
|
||||
];
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
["codex-third", 2],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-second",
|
||||
userMessages,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
@@ -131,10 +157,16 @@ describe("Codex Rewind", () => {
|
||||
}
|
||||
|
||||
const codex = new PaginatedCodex();
|
||||
const userMessages = [
|
||||
{ messageId: "codex-first", turnId: "turn-first" },
|
||||
{ messageId: "codex-second", turnId: "turn-second" },
|
||||
];
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
]),
|
||||
new Map([
|
||||
["codex-first", "turn-first"],
|
||||
["codex-second", "turn-second"],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
@@ -144,7 +176,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessages,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
@@ -177,7 +209,7 @@ describe("Codex Rewind", () => {
|
||||
}
|
||||
|
||||
const codex = new PaginatedCodex();
|
||||
const userMessages = [{ messageId: "codex-first", turnId: null }];
|
||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await expect(
|
||||
@@ -188,7 +220,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessages,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
@@ -203,14 +235,14 @@ describe("Codex Rewind", () => {
|
||||
|
||||
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessages = [{ messageId: "codex-first", turnId: null }];
|
||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||
|
||||
await expect(
|
||||
revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "missing-message",
|
||||
userMessages,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: () => undefined,
|
||||
}),
|
||||
@@ -218,47 +250,4 @@ describe("Codex Rewind", () => {
|
||||
expect(codex.recordedForks).toEqual([]);
|
||||
expect(codex.recordedRollbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("rolls back whole turns when a later turn holds a steer message", async () => {
|
||||
const codex = new FakeCodex();
|
||||
|
||||
await revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-second",
|
||||
userMessages: [
|
||||
{ messageId: "codex-first", turnId: "turn-first" },
|
||||
{ messageId: "codex-second", turnId: "turn-second" },
|
||||
{ messageId: "codex-steer", turnId: "turn-second" },
|
||||
],
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: () => undefined,
|
||||
});
|
||||
|
||||
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 1 }]);
|
||||
});
|
||||
|
||||
test("declines to rewind a steer message without forking", async () => {
|
||||
const codex = new FakeCodex();
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await expect(
|
||||
revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-steer",
|
||||
userMessages: [
|
||||
{ messageId: "codex-first", turnId: "turn-first" },
|
||||
{ messageId: "codex-steer", turnId: "turn-first" },
|
||||
],
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Select the first message in the turn instead");
|
||||
expect(codex.recordedForks).toEqual([]);
|
||||
expect(codex.recordedRollbacks).toEqual([]);
|
||||
expect(reboundThreadId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,9 @@ export interface CodexRewindClient {
|
||||
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface CodexUserMessage {
|
||||
messageId: string;
|
||||
turnId: string | null;
|
||||
export interface CodexUserMessageTurnIndex {
|
||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
@@ -60,12 +60,6 @@ async function rollbackCodexThread(
|
||||
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
|
||||
}
|
||||
|
||||
function countTurns(userMessages: readonly CodexUserMessage[]): number {
|
||||
return userMessages.filter(
|
||||
(message, index) => !message.turnId || message.turnId !== userMessages[index - 1]?.turnId,
|
||||
).length;
|
||||
}
|
||||
|
||||
export async function revertCodexConversation(input: {
|
||||
client: CodexRewindClient;
|
||||
threadId: string | null;
|
||||
@@ -74,7 +68,7 @@ export async function revertCodexConversation(input: {
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
userMessages: readonly CodexUserMessage[];
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
threadRollbackAvailable: boolean;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
@@ -82,21 +76,16 @@ export async function revertCodexConversation(input: {
|
||||
throw new Error("Codex thread is not ready for rewind");
|
||||
}
|
||||
|
||||
const targetIndex = input.userMessages.findIndex(
|
||||
(message) => message.messageId === input.messageId,
|
||||
);
|
||||
if (targetIndex === -1) {
|
||||
const targetTurn = input.userMessageTurns.resolve(input.messageId);
|
||||
if (targetTurn === null) {
|
||||
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
||||
}
|
||||
const targetTurn = input.userMessages[targetIndex];
|
||||
// Codex rewinds whole turns; a steer cannot be removed without the earlier
|
||||
// messages of its turn.
|
||||
if (targetTurn.turnId && input.userMessages[targetIndex - 1]?.turnId === targetTurn.turnId) {
|
||||
throw new Error(
|
||||
"Codex cannot rewind a message sent during a turn without removing earlier messages. Select the first message in the turn instead.",
|
||||
);
|
||||
|
||||
const currentUserTurnCount = input.userMessageTurns.count();
|
||||
const numTurns = currentUserTurnCount - targetTurn.index;
|
||||
if (numTurns < 0) {
|
||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||
}
|
||||
const numTurns = countTurns(input.userMessages.slice(targetIndex));
|
||||
|
||||
// Codex does not carry the parent thread's config into a fork; without it the
|
||||
// forked thread falls back to the default model provider.
|
||||
|
||||
@@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean {
|
||||
try {
|
||||
const notification = JSON.parse(raw) as unknown;
|
||||
if (!notification || typeof notification !== "object") return false;
|
||||
const payload = notification as { matcher?: unknown; reason?: unknown };
|
||||
return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt";
|
||||
const payload = notification as { notification_type?: unknown };
|
||||
return payload.notification_type === "idle_prompt";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user