mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4b94acda | ||
|
|
954046f671 | ||
|
|
ff89c85255 | ||
|
|
1d8a138d0d | ||
|
|
a5c6acb73b |
@@ -49,6 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
|||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import {
|
||||||
|
shouldAutoReplayQueuedAgentMessage,
|
||||||
|
takeQueuedAgentMessageReplay,
|
||||||
|
type QueuedAgentReplaySource,
|
||||||
|
} from "@/contexts/session-queued-message-replay";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -272,7 +277,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||||
const sendAgentMessageRef = useRef<
|
const sendAgentMessageRef = useRef<
|
||||||
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
|
((
|
||||||
|
agentId: string,
|
||||||
|
message: string,
|
||||||
|
images?: AttachmentMetadata[],
|
||||||
|
messageId?: string,
|
||||||
|
) => Promise<void>) | null
|
||||||
>(null);
|
>(null);
|
||||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||||
@@ -349,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
);
|
);
|
||||||
|
|
||||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||||
(agent: Agent) => {
|
(agent: Agent, options?: { source?: QueuedAgentReplaySource }) => {
|
||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
const current = prev.get(agent.id);
|
const current = prev.get(agent.id);
|
||||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||||
@@ -423,17 +433,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
||||||
if (prevStatus === "running" && agent.status !== "running") {
|
if (
|
||||||
|
shouldAutoReplayQueuedAgentMessage({
|
||||||
|
previousStatus: prevStatus,
|
||||||
|
nextStatus: agent.status,
|
||||||
|
source: options?.source ?? "live",
|
||||||
|
})
|
||||||
|
) {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
const queue = session?.queuedMessages.get(agent.id);
|
const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id));
|
||||||
if (queue && queue.length > 0) {
|
if (replay) {
|
||||||
const [next, ...rest] = queue;
|
|
||||||
if (sendAgentMessageRef.current) {
|
if (sendAgentMessageRef.current) {
|
||||||
void sendAgentMessageRef.current(agent.id, next.text, next.images);
|
void sendAgentMessageRef.current(
|
||||||
|
agent.id,
|
||||||
|
replay.text,
|
||||||
|
replay.images,
|
||||||
|
replay.messageId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setQueuedMessages(serverId, (prev) => {
|
setQueuedMessages(serverId, (prev) => {
|
||||||
const updated = new Map(prev);
|
const updated = new Map(prev);
|
||||||
updated.set(agent.id, rest);
|
updated.set(agent.id, replay.remainingQueue);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -722,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
applyAuthoritativeAgentSnapshot(agent);
|
applyAuthoritativeAgentSnapshot(agent, { source: "live" });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyAuthoritativeAgentSnapshot,
|
applyAuthoritativeAgentSnapshot,
|
||||||
@@ -772,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
if (payload.agent) {
|
if (payload.agent) {
|
||||||
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
|
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
|
||||||
applyAuthoritativeAgentSnapshot({
|
applyAuthoritativeAgentSnapshot(
|
||||||
...normalized,
|
{
|
||||||
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
...normalized,
|
||||||
});
|
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
||||||
|
},
|
||||||
|
{ source: "hydrate" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call pure reducer
|
// Call pure reducer
|
||||||
@@ -1469,11 +1492,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const sendAgentMessage = useCallback(
|
const sendAgentMessage = useCallback(
|
||||||
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
|
async (
|
||||||
const messageId = generateMessageId();
|
agentId: string,
|
||||||
|
message: string,
|
||||||
|
images?: AttachmentMetadata[],
|
||||||
|
messageId?: string,
|
||||||
|
) => {
|
||||||
|
const resolvedMessageId = messageId ?? generateMessageId();
|
||||||
const userMessage: StreamItem = {
|
const userMessage: StreamItem = {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
id: messageId,
|
id: resolvedMessageId,
|
||||||
text: message,
|
text: message,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
@@ -1507,7 +1535,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
void client
|
void client
|
||||||
.sendAgentMessage(agentId, message, {
|
.sendAgentMessage(agentId, message, {
|
||||||
messageId,
|
messageId: resolvedMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
shouldAutoReplayQueuedAgentMessage,
|
||||||
|
takeQueuedAgentMessageReplay,
|
||||||
|
} from "./session-queued-message-replay";
|
||||||
|
|
||||||
|
describe("takeQueuedAgentMessageReplay", () => {
|
||||||
|
it("preserves the queued message id for idempotent replay", () => {
|
||||||
|
const replay = takeQueuedAgentMessageReplay([
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
text: "resume this",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachment:img-1",
|
||||||
|
fileName: "test.png",
|
||||||
|
byteSize: 12,
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "msg-2",
|
||||||
|
text: "second",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(replay).toEqual({
|
||||||
|
messageId: "msg-1",
|
||||||
|
text: "resume this",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachment:img-1",
|
||||||
|
fileName: "test.png",
|
||||||
|
byteSize: 12,
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remainingQueue: [{ id: "msg-2", text: "second" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the queue is empty", () => {
|
||||||
|
expect(takeQueuedAgentMessageReplay([])).toBeNull();
|
||||||
|
expect(takeQueuedAgentMessageReplay(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only auto-replays queued messages for live running-to-idle transitions", () => {
|
||||||
|
expect(
|
||||||
|
shouldAutoReplayQueuedAgentMessage({
|
||||||
|
previousStatus: "running",
|
||||||
|
nextStatus: "idle",
|
||||||
|
source: "live",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldAutoReplayQueuedAgentMessage({
|
||||||
|
previousStatus: "running",
|
||||||
|
nextStatus: "idle",
|
||||||
|
source: "hydrate",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldAutoReplayQueuedAgentMessage({
|
||||||
|
previousStatus: "idle",
|
||||||
|
nextStatus: "idle",
|
||||||
|
source: "live",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
|
||||||
|
export interface QueuedAgentMessageReplayItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
images?: AttachmentMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueuedAgentMessageReplay {
|
||||||
|
messageId: string;
|
||||||
|
text: string;
|
||||||
|
images?: AttachmentMetadata[];
|
||||||
|
remainingQueue: QueuedAgentMessageReplayItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueuedAgentReplaySource = "hydrate" | "live";
|
||||||
|
|
||||||
|
export function shouldAutoReplayQueuedAgentMessage(input: {
|
||||||
|
previousStatus: string | undefined;
|
||||||
|
nextStatus: string;
|
||||||
|
source: QueuedAgentReplaySource;
|
||||||
|
}): boolean {
|
||||||
|
return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeQueuedAgentMessageReplay(
|
||||||
|
queue: readonly QueuedAgentMessageReplayItem[] | undefined,
|
||||||
|
): QueuedAgentMessageReplay | null {
|
||||||
|
if (!queue || queue.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [next, ...remainingQueue] = queue;
|
||||||
|
return {
|
||||||
|
messageId: next.id,
|
||||||
|
text: next.text,
|
||||||
|
images: next.images,
|
||||||
|
remainingQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1588,6 +1588,34 @@ describe("AgentManager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("notifyAgentState does not mutate updatedAt", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = manager.getAgent(snapshot.id);
|
||||||
|
expect(before).toBeDefined();
|
||||||
|
|
||||||
|
manager.notifyAgentState(snapshot.id);
|
||||||
|
|
||||||
|
const after = manager.getAgent(snapshot.id);
|
||||||
|
expect(after).toBeDefined();
|
||||||
|
expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
|
|||||||
|
|
||||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||||
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
|
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
|
||||||
|
const RECENT_USER_MESSAGE_REPLAY_WINDOW_MS = 15_000;
|
||||||
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
||||||
const AgentIdSchema = z.string().uuid();
|
const AgentIdSchema = z.string().uuid();
|
||||||
|
|
||||||
@@ -847,7 +848,6 @@ export class AgentManager {
|
|||||||
if (!agent || agent.internal) {
|
if (!agent || agent.internal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.touchUpdatedAt(agent);
|
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,6 +931,66 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
classifyRecordedUserMessage(
|
||||||
|
agentId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { messageId?: string },
|
||||||
|
): "new" | "duplicate" | "conflict" {
|
||||||
|
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
||||||
|
if (!normalizedMessageId) {
|
||||||
|
return "new";
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = this.requireAgent(agentId);
|
||||||
|
for (const row of agent.timelineRows) {
|
||||||
|
if (row.item.type !== "user_message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rowMessageId = normalizeMessageId(row.item.messageId);
|
||||||
|
if (rowMessageId !== normalizedMessageId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return row.item.text === text ? "duplicate" : "conflict";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRecentUserMessageReplay(agent, text)) {
|
||||||
|
return "duplicate";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "new";
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecentUserMessageReplay(agent: ManagedAgent, text: string): boolean {
|
||||||
|
let latestUserMessageRow: AgentTimelineRow | null = null;
|
||||||
|
for (let index = agent.timelineRows.length - 1; index >= 0; index -= 1) {
|
||||||
|
const row = agent.timelineRows[index];
|
||||||
|
if (!row || row.item.type !== "user_message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
latestUserMessageRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUserMessageItem =
|
||||||
|
latestUserMessageRow?.item.type === "user_message" ? latestUserMessageRow.item : null;
|
||||||
|
if (!latestUserMessageRow || !latestUserMessageItem || latestUserMessageItem.text !== text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.lifecycle === "running" || agent.pendingRun || agent.pendingReplacement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowTimestampMs = Date.parse(latestUserMessageRow.timestamp);
|
||||||
|
const latestActivityMs = Math.max(
|
||||||
|
Number.isFinite(rowTimestampMs) ? rowTimestampMs : 0,
|
||||||
|
agent.updatedAt.getTime(),
|
||||||
|
agent.lastUserMessageAt?.getTime() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Date.now() - latestActivityMs <= RECENT_USER_MESSAGE_REPLAY_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
__codexAppServerInternals,
|
__codexAppServerInternals,
|
||||||
@@ -97,4 +98,47 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(item.detail.newString).toBeUndefined();
|
expect(item.detail.newString).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not buffer terminal Codex events before turn_started", () => {
|
||||||
|
const terminalEvents: AgentStreamEvent[] = [
|
||||||
|
{ type: "turn_completed", provider: "codex", usage: undefined },
|
||||||
|
{ type: "turn_failed", provider: "codex", error: "boom" },
|
||||||
|
{ type: "turn_canceled", provider: "codex", reason: "interrupted" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of terminalEvents) {
|
||||||
|
expect(__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted(event)).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
item: { type: "assistant_message", text: "stale chunk" },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only synthesizes completion from idle thread status while a run is active", () => {
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||||
|
statusType: "idle",
|
||||||
|
hasActiveRun: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||||
|
statusType: "idle",
|
||||||
|
hasActiveRun: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||||
|
statusType: "running",
|
||||||
|
hasActiveRun: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1195,6 +1195,18 @@ const ThreadStartedNotificationSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
const ThreadStatusChangedNotificationSchema = z
|
||||||
|
.object({
|
||||||
|
threadId: z.string().optional(),
|
||||||
|
status: z
|
||||||
|
.object({
|
||||||
|
type: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
const TurnStartedNotificationSchema = z
|
const TurnStartedNotificationSchema = z
|
||||||
.object({
|
.object({
|
||||||
turn: z.object({ id: z.string() }).passthrough(),
|
turn: z.object({ id: z.string() }).passthrough(),
|
||||||
@@ -1402,6 +1414,16 @@ const ItemFileChangeOutputDeltaNotificationSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
const ItemCommandExecutionOutputDeltaNotificationSchema = z
|
||||||
|
.object({
|
||||||
|
itemId: z.string().optional(),
|
||||||
|
callId: z.string().optional(),
|
||||||
|
stream: z.string().optional(),
|
||||||
|
delta: z.string().optional(),
|
||||||
|
chunk: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
const CodexEventTurnDiffNotificationSchema = z
|
const CodexEventTurnDiffNotificationSchema = z
|
||||||
.object({
|
.object({
|
||||||
msg: z
|
msg: z
|
||||||
@@ -1416,6 +1438,7 @@ const CodexEventTurnDiffNotificationSchema = z
|
|||||||
|
|
||||||
type ParsedCodexNotification =
|
type ParsedCodexNotification =
|
||||||
| { kind: "thread_started"; threadId: string }
|
| { kind: "thread_started"; threadId: string }
|
||||||
|
| { kind: "thread_status_changed"; threadId: string | null; statusType: string | null }
|
||||||
| { kind: "turn_started"; turnId: string }
|
| { kind: "turn_started"; turnId: string }
|
||||||
| { kind: "turn_completed"; status: string; errorMessage: string | null }
|
| { kind: "turn_completed"; status: string; errorMessage: string | null }
|
||||||
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
|
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
|
||||||
@@ -1499,6 +1522,25 @@ const CodexNotificationSchema = z.union([
|
|||||||
params,
|
params,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
method: z.literal("thread/status/changed"),
|
||||||
|
params: ThreadStatusChangedNotificationSchema,
|
||||||
|
})
|
||||||
|
.transform(
|
||||||
|
({ params }): ParsedCodexNotification => ({
|
||||||
|
kind: "thread_status_changed",
|
||||||
|
threadId: params.threadId ?? null,
|
||||||
|
statusType: params.status?.type ?? null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
z.object({ method: z.literal("thread/status/changed"), params: z.unknown() }).transform(
|
||||||
|
({ method, params }): ParsedCodexNotification => ({
|
||||||
|
kind: "invalid_payload",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
),
|
||||||
z
|
z
|
||||||
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
|
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
|
||||||
.transform(
|
.transform(
|
||||||
@@ -1872,6 +1914,28 @@ const CodexNotificationSchema = z.union([
|
|||||||
params,
|
params,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
method: z.literal("item/commandExecution/outputDelta"),
|
||||||
|
params: ItemCommandExecutionOutputDeltaNotificationSchema,
|
||||||
|
})
|
||||||
|
.transform(
|
||||||
|
({ params }): ParsedCodexNotification => ({
|
||||||
|
kind: "exec_command_output_delta",
|
||||||
|
callId: params.callId ?? params.itemId ?? null,
|
||||||
|
stream: params.stream ?? null,
|
||||||
|
chunk: params.delta ?? params.chunk ?? null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
z
|
||||||
|
.object({ method: z.literal("item/commandExecution/outputDelta"), params: z.unknown() })
|
||||||
|
.transform(
|
||||||
|
({ method, params }): ParsedCodexNotification => ({
|
||||||
|
kind: "invalid_payload",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
method: z.literal("codex/event/turn_diff"),
|
method: z.literal("codex/event/turn_diff"),
|
||||||
@@ -2034,8 +2098,37 @@ export async function codexAppServerTurnInputFromPrompt(
|
|||||||
|
|
||||||
export const __codexAppServerInternals = {
|
export const __codexAppServerInternals = {
|
||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
|
shouldBufferCodexEventUntilTurnStarted,
|
||||||
|
shouldSynthesizeTurnCompletionFromThreadStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isTerminalAgentStreamEvent(event: AgentStreamEvent): boolean {
|
||||||
|
return (
|
||||||
|
event.type === "turn_completed" ||
|
||||||
|
event.type === "turn_failed" ||
|
||||||
|
event.type === "turn_canceled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBufferCodexEventUntilTurnStarted(event: AgentStreamEvent): boolean {
|
||||||
|
if (
|
||||||
|
event.type === "permission_requested" ||
|
||||||
|
event.type === "permission_resolved" ||
|
||||||
|
event.type === "turn_started" ||
|
||||||
|
isTerminalAgentStreamEvent(event)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSynthesizeTurnCompletionFromThreadStatus(input: {
|
||||||
|
statusType: string | null;
|
||||||
|
hasActiveRun: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return input.hasActiveRun && input.statusType === "idle";
|
||||||
|
}
|
||||||
|
|
||||||
class CodexAppServerAgentSession implements AgentSession {
|
class CodexAppServerAgentSession implements AgentSession {
|
||||||
readonly provider = CODEX_PROVIDER;
|
readonly provider = CODEX_PROVIDER;
|
||||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||||
@@ -2074,6 +2167,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||||
private latestUsage: AgentUsage | undefined;
|
private latestUsage: AgentUsage | undefined;
|
||||||
|
private runAwaitingTerminal = false;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private collaborationModes: Array<{
|
private collaborationModes: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2500,33 +2594,29 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
||||||
|
this.runAwaitingTerminal = true;
|
||||||
|
|
||||||
let sawTurnStarted = false;
|
let sawTurnStarted = false;
|
||||||
for await (const event of queue) {
|
for await (const event of queue) {
|
||||||
// Drop pre-start timeline noise that can leak from the previous turn.
|
// Drop pre-start timeline noise that can leak from the previous turn.
|
||||||
// Keep permission events, which can legitimately arrive before turn_started.
|
// Keep permission and terminal events, which can legitimately arrive
|
||||||
|
// before turn_started on resumed/replayed requests.
|
||||||
if (!sawTurnStarted) {
|
if (!sawTurnStarted) {
|
||||||
if (event.type === "permission_requested" || event.type === "permission_resolved") {
|
if (shouldBufferCodexEventUntilTurnStarted(event)) {
|
||||||
yield event;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.type === "turn_started") {
|
if (event.type === "turn_started") {
|
||||||
sawTurnStarted = true;
|
sawTurnStarted = true;
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yield event;
|
yield event;
|
||||||
if (
|
if (isTerminalAgentStreamEvent(event)) {
|
||||||
event.type === "turn_completed" ||
|
|
||||||
event.type === "turn_failed" ||
|
|
||||||
event.type === "turn_canceled"
|
|
||||||
) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
this.runAwaitingTerminal = false;
|
||||||
if (this.eventQueue === queue) {
|
if (this.eventQueue === queue) {
|
||||||
this.eventQueue = null;
|
this.eventQueue = null;
|
||||||
}
|
}
|
||||||
@@ -2860,6 +2950,31 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === "thread_status_changed") {
|
||||||
|
if (
|
||||||
|
shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||||
|
statusType: parsed.statusType,
|
||||||
|
hasActiveRun: this.runAwaitingTerminal,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
this.runAwaitingTerminal = false;
|
||||||
|
this.currentTurnId = null;
|
||||||
|
this.emittedItemStartedIds.clear();
|
||||||
|
this.emittedItemCompletedIds.clear();
|
||||||
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
|
this.emittedExecCommandCompletedCallIds.clear();
|
||||||
|
this.pendingCommandOutputDeltas.clear();
|
||||||
|
this.pendingFileChangeOutputDeltas.clear();
|
||||||
|
this.warnedIncompleteEditToolCallIds.clear();
|
||||||
|
this.emitEvent({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
usage: this.latestUsage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.kind === "turn_started") {
|
if (parsed.kind === "turn_started") {
|
||||||
this.currentTurnId = parsed.turnId;
|
this.currentTurnId = parsed.turnId;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
@@ -2874,6 +2989,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === "turn_completed") {
|
if (parsed.kind === "turn_completed") {
|
||||||
|
this.runAwaitingTerminal = false;
|
||||||
|
this.currentTurnId = null;
|
||||||
if (parsed.status === "failed") {
|
if (parsed.status === "failed") {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "send-message-id-replay-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("send_agent_message_request replay", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("does not start a second run when the same client messageId is replayed", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
try {
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "duplicate message replay",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageId = "msg-replay-1";
|
||||||
|
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||||
|
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||||
|
|
||||||
|
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||||
|
expect(afterFirst?.agent.status).toBe("idle");
|
||||||
|
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||||
|
expect(firstUpdatedAt).not.toBeNull();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||||
|
expect(afterReplay?.agent.status).toBe("idle");
|
||||||
|
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not start a second run when identical text is replayed with a new messageId right after finish", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
try {
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "duplicate text replay after finish",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-1" });
|
||||||
|
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||||
|
|
||||||
|
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||||
|
expect(afterFirst?.agent.status).toBe("idle");
|
||||||
|
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||||
|
expect(firstUpdatedAt).not.toBeNull();
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-2" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||||
|
expect(afterReplay?.agent.status).toBe("idle");
|
||||||
|
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||||
|
|
||||||
|
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 20,
|
||||||
|
projection: "canonical",
|
||||||
|
});
|
||||||
|
const userMessages = timeline.entries.filter(
|
||||||
|
(entry) => entry.item.type === "user_message" && entry.item.text === "Reply with exactly hello",
|
||||||
|
);
|
||||||
|
expect(userMessages).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not replace an active run when identical text is resent with a new messageId", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
try {
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
title: "duplicate text replay while running",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-1" });
|
||||||
|
await ctx.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 5_000);
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-2" });
|
||||||
|
const afterReplay = await ctx.client.waitForFinish(agent.id, 5_000);
|
||||||
|
expect(afterReplay.status).toBe("idle");
|
||||||
|
|
||||||
|
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 20,
|
||||||
|
projection: "canonical",
|
||||||
|
});
|
||||||
|
const userMessages = timeline.entries.filter(
|
||||||
|
(entry) => entry.item.type === "user_message" && entry.item.text === "Run: sleep 30",
|
||||||
|
);
|
||||||
|
expect(userMessages).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2610,6 +2610,20 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, text, {
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
if (replayDisposition === "duplicate") {
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ agentId, messageId },
|
||||||
|
"Suppressing duplicate send replay for already-recorded user message",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (replayDisposition === "conflict") {
|
||||||
|
throw new Error(`Client messageId '${messageId}' was reused with different text`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, text, {
|
this.agentManager.recordUserMessage(agentId, text, {
|
||||||
messageId,
|
messageId,
|
||||||
@@ -6626,6 +6640,38 @@ export class Session {
|
|||||||
|
|
||||||
await this.ensureAgentLoaded(agentId);
|
await this.ensureAgentLoaded(agentId);
|
||||||
|
|
||||||
|
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, msg.text, {
|
||||||
|
messageId: msg.messageId,
|
||||||
|
});
|
||||||
|
if (replayDisposition === "duplicate") {
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ agentId, messageId: msg.messageId, requestId: msg.requestId },
|
||||||
|
"Suppressing duplicate send_agent_message_request replay",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "send_agent_message_response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
agentId,
|
||||||
|
accepted: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (replayDisposition === "conflict") {
|
||||||
|
this.emit({
|
||||||
|
type: "send_agent_message_response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
agentId,
|
||||||
|
accepted: false,
|
||||||
|
error: `Client messageId '${msg.messageId}' was reused with different text`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||||||
messageId: msg.messageId,
|
messageId: msg.messageId,
|
||||||
|
|||||||
Reference in New Issue
Block a user