Stop completed turns from appearing stuck (#2484)

* fix(app): stop completed turns from appearing stuck

Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow.

* fix(app): guard pending message identity

* test(app): measure submission layout independent of scroll

* fix(app): keep submitted messages consistent through reconnects

Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization.

* fix(app): close canonical submission races

* test(app): cover canonical submission races in browser

* fix(app): keep replacement submissions authoritative

Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle.

* fix(app): keep submitted messages stable across sync

Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows.

* fix(app): settle submissions in either acknowledgement order

Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity.

* fix(app): keep agent visible during history handoff

Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied.

* fix(app): settle attachment-only submissions

Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row.

* fix(agent): settle out-of-band message submissions

Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.

* fix(app): settle out-of-band submissions compatibly

* fix(app): preserve canonical prompt order

* test(app): keep workspace status check timing-independent

The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
This commit is contained in:
Mohamed Boudra
2026-07-27 21:27:10 +02:00
committed by GitHub
parent 1f253d92e2
commit fa1198c2be
39 changed files with 3416 additions and 1048 deletions
@@ -49,6 +49,25 @@ describe("MockLoadTestAgentClient", () => {
});
});
test("rejects the configured number of prompts before starting a retry", async () => {
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
});
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
"Requested mock prompt rejection",
);
await expect(session.startTurn("Accept this retry.")).resolves.toEqual({
turnId: expect.any(String),
});
await session.interrupt();
});
test("returns schema-shaped JSON for structured branch-name generation", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
@@ -583,6 +583,7 @@ export class MockLoadTestAgentSession implements AgentSession {
private modeId: string | null;
private modelId: string | null;
private readonly rewindError: string | null;
private remainingPromptRejections: number;
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
this.id = options.sessionId;
@@ -593,6 +594,13 @@ export class MockLoadTestAgentSession implements AgentSession {
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
: null;
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
this.remainingPromptRejections =
typeof requestedPromptRejections === "number" &&
Number.isSafeInteger(requestedPromptRejections) &&
requestedPromptRejections > 0
? requestedPromptRejections
: 0;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -611,6 +619,10 @@ export class MockLoadTestAgentSession implements AgentSession {
if (this.activeTurn) {
throw new Error("Mock load-test provider already has an active turn");
}
if (this.remainingPromptRejections > 0) {
this.remainingPromptRejections -= 1;
throw new Error("Requested mock prompt rejection");
}
const profile = resolveModelProfile(this.modelId);
const turnId = randomUUID();
+2
View File
@@ -6344,6 +6344,7 @@ export class Session {
agentId,
accepted: true,
error: null,
outOfBand: true,
},
});
return;
@@ -6371,6 +6372,7 @@ export class Session {
agentId,
accepted: true,
error: null,
outOfBand: false,
},
});
} catch (error) {