fix(app): keep submitted prompts in timeline order (#2259)

Canonical provider message IDs and optimistic client IDs occupy different namespaces. Preserve both identities for deterministic reconciliation while retaining a dated content fallback for older daemon timelines.
This commit is contained in:
Mohamed Boudra
2026-07-20 17:29:52 +02:00
committed by GitHub
parent 07d988488e
commit e1bda8e498
22 changed files with 240 additions and 42 deletions
@@ -7881,7 +7881,12 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
type: "timeline",
provider: this.provider,
turnId,
item: { type: "user_message", text, messageId: options?.messageId },
item: {
type: "user_message",
text,
messageId: "provider-message-1",
clientMessageId: options?.clientMessageId,
},
});
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
@@ -7907,13 +7912,16 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
workspaceId: undefined,
});
await manager.runAgent(snapshot.id, "hello from composer", { messageId: "msg-client-1" });
await manager.runAgent(snapshot.id, "hello from composer", {
clientMessageId: "msg-client-1",
});
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
expect(timeline.map((row) => row.item)).toContainEqual({
type: "user_message",
text: "hello from composer",
messageId: "msg-client-1",
messageId: "provider-message-1",
clientMessageId: "msg-client-1",
});
} finally {
await manager.flush().catch(() => undefined);
@@ -190,7 +190,7 @@ test("sendPromptToAgent forwards the client message id as run options", async ()
expect(streamAgentSpy).toHaveBeenCalledWith("agent-1", "hello", {
outputSchema: { type: "object" },
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
});
});
@@ -198,7 +198,7 @@ export async function sendPromptToAgent(
}
const runOptions = params.messageId
? { ...params.runOptions, messageId: params.messageId }
? { ...params.runOptions, clientMessageId: params.messageId }
: params.runOptions;
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
@@ -199,7 +199,7 @@ export interface AgentRunOptions {
outputSchema?: unknown;
resumeFrom?: AgentPersistenceHandle;
maxThinkingTokens?: number;
messageId?: string;
clientMessageId?: string;
}
export interface AgentUsage {
@@ -368,7 +368,7 @@ export interface CompactionTimelineItem {
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string }
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
@@ -75,7 +75,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
});
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
messageId: "msg-create-1",
clientMessageId: "msg-create-1",
});
});
@@ -271,7 +271,7 @@ async function resolveSessionCreateAgent(
input.outputSchema || clientMessageId
? {
...(input.outputSchema ? { outputSchema: input.outputSchema } : {}),
...(clientMessageId ? { messageId: clientMessageId } : {}),
...(clientMessageId ? { clientMessageId } : {}),
}
: undefined;
const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId;
@@ -2329,7 +2329,9 @@ describe("ACPAgentSession", () => {
events.push(event);
});
const { turnId } = await session.startTurn("hello", { messageId: "msg-client-1" });
const { turnId } = await session.startTurn("hello", {
clientMessageId: "msg-client-1",
});
expect(prompt).toHaveBeenCalledWith({
sessionId: "session-1",
@@ -2343,7 +2345,12 @@ describe("ACPAgentSession", () => {
type: "timeline",
provider: "claude-acp",
turnId,
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
item: {
type: "user_message",
text: "hello",
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
},
},
]);
@@ -2362,7 +2369,7 @@ describe("ACPAgentSession", () => {
events.push(event);
});
await session.startTurn("hello", { messageId: "msg-client-1" });
await session.startTurn("hello", { clientMessageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
@@ -2389,7 +2396,7 @@ describe("ACPAgentSession", () => {
events.push(event);
});
await session.startTurn("hello", { messageId: "msg-client-1" });
await session.startTurn("hello", { clientMessageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
@@ -2404,7 +2411,12 @@ describe("ACPAgentSession", () => {
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
item: {
type: "user_message",
text: "hello",
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
},
turnId: expect.any(String),
},
]);
@@ -2428,7 +2440,7 @@ describe("ACPAgentSession", () => {
events.push(event);
});
await session.startTurn("first", { messageId: "msg-client-1" });
await session.startTurn("first", { clientMessageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
@@ -2440,7 +2452,7 @@ describe("ACPAgentSession", () => {
await Promise.resolve();
await Promise.resolve();
await session.startTurn("second", { messageId: "msg-client-2" });
await session.startTurn("second", { clientMessageId: "msg-client-2" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
@@ -2455,13 +2467,23 @@ describe("ACPAgentSession", () => {
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "first", messageId: "msg-client-1" },
item: {
type: "user_message",
text: "first",
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
},
turnId: expect.any(String),
},
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "second", messageId: "msg-client-2" },
item: {
type: "user_message",
text: "second",
messageId: "msg-client-2",
clientMessageId: "msg-client-2",
},
turnId: expect.any(String),
},
]);
@@ -2479,7 +2501,7 @@ describe("ACPAgentSession", () => {
events.push(event);
});
await session.startTurn("hello", { messageId: "msg-client-1" });
await session.startTurn("hello", { clientMessageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
@@ -2495,7 +2517,12 @@ describe("ACPAgentSession", () => {
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
item: {
type: "user_message",
text: "hello",
messageId: "msg-client-1",
clientMessageId: "msg-client-1",
},
turnId: expect.any(String),
},
]);
@@ -1474,13 +1474,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
const turnId = randomUUID();
const messageId = options?.messageId ?? randomUUID();
const messageId = options?.clientMessageId ?? randomUUID();
this.activeForegroundTurnId = turnId;
this.fallbackAssistantMessageId = null;
this.activeSubmittedUserMessage = null;
this.emitBootstrapThreadEvent();
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.emitSubmittedUserMessage(prompt, messageId, turnId);
this.emitSubmittedUserMessage(prompt, messageId, turnId, options?.clientMessageId);
void this.connection
.prompt({
@@ -2687,6 +2687,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
prompt: AgentPromptInput,
messageId: string,
turnId: string,
clientMessageId?: string,
): void {
const text = extractPromptText(prompt);
if (text.trim().length === 0) {
@@ -2698,7 +2699,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
type: "timeline",
provider: this.provider,
turnId,
item: { type: "user_message", text, messageId },
item: {
type: "user_message",
text,
messageId,
...(clientMessageId ? { clientMessageId } : {}),
},
});
}
@@ -2043,7 +2043,7 @@ class ClaudeAgentSession implements AgentSession {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.closed) {
throw new Error("Claude session is closed");
@@ -2109,7 +2109,7 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
this.emitSubmittedUserMessage(sdkMessage, turnId);
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
}
}, 0);
} catch (error) {
@@ -3686,6 +3686,7 @@ class ClaudeAgentSession implements AgentSession {
private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): void {
const events: AgentStreamEvent[] = [];
this.appendUserMessageEvents(message, events);
@@ -3695,7 +3696,11 @@ class ClaudeAgentSession implements AgentSession {
this.foregroundHasVisibleActivity = true;
for (const event of events) {
if (event.type === "timeline") {
this.notifySubscribers({ ...event, turnId });
const item =
event.item.type === "user_message" && clientMessageId
? { ...event.item, clientMessageId }
: event.item;
this.notifySubscribers({ ...event, item, turnId });
} else {
this.notifySubscribers(event);
}
@@ -1014,6 +1014,30 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("correlates a Codex user message with the submitting client message", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember this", { clientMessageId: "client-message" });
const userMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, { id: "codex-message", text: "remember this" });
await expect(userMessage).resolves.toMatchObject({
item: {
type: "user_message",
messageId: "codex-message",
clientMessageId: "client-message",
},
});
appServer.completeTurn();
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
@@ -3096,6 +3096,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
@@ -3820,6 +3821,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.currentTurnId = null;
try {
@@ -3835,6 +3837,7 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
} catch (error) {
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
throw error;
}
@@ -4238,6 +4241,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
if (this.client) {
await this.client.dispose();
}
@@ -5287,6 +5291,7 @@ export class CodexAppServerAgentSession implements AgentSession {
});
}
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.pendingSubAgentNotificationsByThreadId.clear();
this.resetTurnTrackingState();
}
@@ -5825,7 +5830,11 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
const item = this.activeClientMessageId
? { ...timelineItem, clientMessageId: this.activeClientMessageId }
: timelineItem;
this.activeClientMessageId = null;
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
}
private warnUnknownNotificationMethod(method: string, params: unknown): void {
@@ -606,7 +606,7 @@ export class MockLoadTestAgentSession implements AgentSession {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.activeTurn) {
throw new Error("Mock load-test provider already has an active turn");
@@ -648,6 +648,7 @@ export class MockLoadTestAgentSession implements AgentSession {
type: "user_message",
text: promptToText(prompt),
messageId: userMessageId,
...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
},
});
}, 0);
@@ -896,6 +896,7 @@ export class OmpAgentSession implements AgentSession {
private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null;
private activeTurnStarted = false;
private activeTurnHasUserMessage = false;
@@ -974,7 +975,7 @@ export class OmpAgentSession implements AgentSession {
});
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<StartTurnResult> {
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
if (this.activeTurnId) {
throw new Error("An OMP turn is already active");
}
@@ -983,6 +984,7 @@ export class OmpAgentSession implements AgentSession {
const turnId = randomUUID();
this.live = true;
this.activeTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
@@ -1010,6 +1012,7 @@ export class OmpAgentSession implements AgentSession {
return;
}
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1143,6 +1146,7 @@ export class OmpAgentSession implements AgentSession {
}
if (turnId && this.activeTurnId === turnId) {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1329,6 +1333,7 @@ export class OmpAgentSession implements AgentSession {
item: {
type: "user_message",
text: promptText,
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
},
});
}
@@ -1764,6 +1769,7 @@ export class OmpAgentSession implements AgentSession {
}
const turnId = this.activeTurnId;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.clearNoTurnBuffers();
@@ -2003,6 +2009,7 @@ export class OmpAgentSession implements AgentSession {
}
const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown };
const messageId = readNativeMessageId(nativeMessage);
const clientMessageId = this.activeClientMessageId;
const emitUserMessage = (resolvedMessageId?: string): void => {
if (resolvedMessageId) {
// OMP re-emits user message_end frames for entries it has already
@@ -2021,6 +2028,7 @@ export class OmpAgentSession implements AgentSession {
type: "user_message",
text,
...(resolvedMessageId ? { messageId: resolvedMessageId } : {}),
...(clientMessageId ? { clientMessageId } : {}),
},
});
};
@@ -2085,6 +2093,7 @@ export class OmpAgentSession implements AgentSession {
private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
@@ -1707,6 +1707,7 @@ export interface OpenCodeEventTranslationState {
cwd?: string;
messageRoles: Map<string, OpenCodeMessageRole>;
pendingUserMessageText?: string | null;
pendingClientMessageId?: string | null;
emittedUserMessageIds?: Set<string>;
accumulatedUsage: AgentUsage;
sessionTotalCostUsd?: number;
@@ -2370,7 +2371,12 @@ function appendOpenCodeUserMessageUpdated(
events.push({
type: "timeline",
provider: "opencode",
item: { type: "user_message", text, messageId: info.id },
item: {
type: "user_message",
text,
messageId: info.id,
...(state.pendingClientMessageId ? { clientMessageId: state.pendingClientMessageId } : {}),
},
});
}
@@ -2895,6 +2901,7 @@ class OpenCodeAgentSession implements AgentSession {
/** Tracks the role of each message by ID to distinguish user from assistant messages */
private messageRoles = new Map<string, OpenCodeMessageRole>();
private pendingUserMessageText: string | null = null;
private pendingClientMessageId: string | null = null;
private emittedUserMessageIds = new Set<string>();
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
private streamedPartKeys = new Set<string>();
@@ -3103,6 +3110,7 @@ class OpenCodeAgentSession implements AgentSession {
const parts = buildOpenCodePromptParts(prompt);
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
this.pendingClientMessageId = options?.clientMessageId ?? null;
this.suppressAssistantMessagesUntilIdle.active = false;
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
@@ -3645,6 +3653,7 @@ class OpenCodeAgentSession implements AgentSession {
this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear();
this.pendingUserMessageText = null;
this.pendingClientMessageId = null;
this.abortController = null;
this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId);
return turnId;
@@ -3670,6 +3679,7 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.clear();
}
this.pendingUserMessageText = null;
this.pendingClientMessageId = null;
this.activeForegroundTurnId = null;
this.activeForegroundTurnSource = null;
this.abortController = null;
@@ -4068,6 +4078,7 @@ class OpenCodeAgentSession implements AgentSession {
cwd: this.config.cwd,
messageRoles: this.messageRoles,
pendingUserMessageText: this.pendingUserMessageText,
pendingClientMessageId: this.pendingClientMessageId,
emittedUserMessageIds: this.emittedUserMessageIds,
accumulatedUsage: this.accumulatedUsage,
sessionTotalCostUsd: this.sessionTotalCostUsd,
@@ -258,6 +258,7 @@ interface PiCapturedEntry extends PiCapturedUserMessageEntry {
interface PendingPiUserMessage {
text: string;
turnId: string | undefined;
clientMessageId?: string;
}
interface PendingExtensionResult {
@@ -1179,6 +1180,7 @@ export class PiRpcAgentSession implements AgentSession {
private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null;
private activeTurnStarted = false;
private activeNoTurnPromptText: string | null = null;
@@ -1240,7 +1242,7 @@ export class PiRpcAgentSession implements AgentSession {
});
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<StartTurnResult> {
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
if (this.activeTurnId) {
throw new Error("A Pi turn is already active");
}
@@ -1248,6 +1250,7 @@ export class PiRpcAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
this.activePromptRequestId = null;
@@ -1278,6 +1281,7 @@ export class PiRpcAgentSession implements AgentSession {
return;
}
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeAssistantMessageId = null;
this.clearNoTurnBuffers();
@@ -1393,6 +1397,7 @@ export class PiRpcAgentSession implements AgentSession {
await this.runtimeSession.abort();
if (turnId && this.activeTurnId === turnId) {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeAssistantMessageId = null;
this.clearNoTurnBuffers();
@@ -1574,6 +1579,7 @@ export class PiRpcAgentSession implements AgentSession {
item: {
type: "user_message",
text: promptText,
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
},
});
}
@@ -1796,6 +1802,7 @@ export class PiRpcAgentSession implements AgentSession {
type: "user_message",
text: pending.text,
messageId: entry.id,
...(pending.clientMessageId ? { clientMessageId: pending.clientMessageId } : {}),
},
});
}
@@ -1961,6 +1968,7 @@ export class PiRpcAgentSession implements AgentSession {
}
const turnId = this.activeTurnId;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.clearNoTurnBuffers();
this.emit({
@@ -2170,7 +2178,11 @@ export class PiRpcAgentSession implements AgentSession {
if (!text) {
return;
}
this.pendingUserMessages.push({ text, turnId });
this.pendingUserMessages.push({
text,
turnId,
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
});
void this.requestEntryCapture("message_end").catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.emit({
@@ -2221,6 +2233,7 @@ export class PiRpcAgentSession implements AgentSession {
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
this.clearNoTurnBuffers();