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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,5 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'

## Mode: Plan

- When Plan Mode is requested, assume the user wants the actual completed plan; do not merely describe how you would devise one.
- Attach a net LoC estimate (product code only) to each recommended approach.
2 changes: 2 additions & 0 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export class StreamingMessageAggregator {
// Track when we're waiting for stream-start after user message
// Prevents retry barrier flash during normal send flow
// Stores timestamp of when user message was sent (null = no pending stream)
// IMPORTANT: We intentionally keep this timestamp until a stream actually starts
// (or the user retries) so retry UI/backoff logic doesn't misfire on send failures.
private pendingStreamStartTime: number | null = null;

// Workspace creation timestamp (used for recency calculation)
Expand Down
19 changes: 10 additions & 9 deletions src/browser/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
hasInterruptedStream,
isEligibleForAutoRetry,
isNonRetryableSendError,
PENDING_STREAM_START_GRACE_PERIOD_MS,
} from "./retryEligibility";
import type { DisplayedMessage } from "@/common/types/message";
import type { SendMessageError } from "@/common/types/errors";
Expand Down Expand Up @@ -165,7 +166,7 @@ describe("hasInterruptedStream", () => {
expect(hasInterruptedStream(messages, null)).toBe(true);
});

it("returns false when message was sent very recently (< 3s)", () => {
it("returns false when message was sent very recently (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand Down Expand Up @@ -194,8 +195,8 @@ describe("hasInterruptedStream", () => {
historySequence: 3,
},
];
// Message sent 1 second ago - still within 3s window
const recentTimestamp = Date.now() - 1000;
// Message sent 1 second ago - still within grace window
const recentTimestamp = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 1000);
expect(hasInterruptedStream(messages, recentTimestamp)).toBe(false);
});

Expand All @@ -212,7 +213,7 @@ describe("hasInterruptedStream", () => {
expect(hasInterruptedStream(messages, null)).toBe(true);
});

it("returns false when user message just sent (< 3s ago)", () => {
it("returns false when user message just sent (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -222,11 +223,11 @@ describe("hasInterruptedStream", () => {
historySequence: 1,
},
];
const justSent = Date.now() - 500; // 0.5s ago
const justSent = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 500);
expect(hasInterruptedStream(messages, justSent)).toBe(false);
});

it("returns true when message sent over 3s ago (stream likely hung)", () => {
it("returns true when message sent beyond grace period (stream likely hung)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -236,7 +237,7 @@ describe("hasInterruptedStream", () => {
historySequence: 1,
},
];
const longAgo = Date.now() - 4000; // 4s ago - past 3s threshold
const longAgo = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS + 1000);
expect(hasInterruptedStream(messages, longAgo)).toBe(true);
});

Expand Down Expand Up @@ -545,7 +546,7 @@ describe("isEligibleForAutoRetry", () => {
expect(isEligibleForAutoRetry(messages, null)).toBe(true);
});

it("returns false when user message sent very recently (< 3s)", () => {
it("returns false when user message sent very recently (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -555,7 +556,7 @@ describe("isEligibleForAutoRetry", () => {
historySequence: 1,
},
];
const justSent = Date.now() - 500; // 0.5s ago
const justSent = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 500);
expect(isEligibleForAutoRetry(messages, justSent)).toBe(false);
});
});
Expand Down
10 changes: 6 additions & 4 deletions src/browser/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ declare global {
}
}

export const PENDING_STREAM_START_GRACE_PERIOD_MS = 15000; // 15 seconds

/**
* Check if the debug flag to force all errors to be retryable is enabled
*/
Expand Down Expand Up @@ -69,20 +71,20 @@ export function isNonRetryableSendError(error: SendMessageError): boolean {
* 3. Last message is a user message (indicating we sent it but never got a response)
* - This handles app restarts during slow model responses (models can take 30-60s to first token)
* - User messages are only at the end when response hasn't started/completed
* - EXCEPT: Not if recently sent (<3s ago) - prevents flash during normal send flow
* - EXCEPT: Not if recently sent (within PENDING_STREAM_START_GRACE_PERIOD_MS) - prevents flash during normal send flow
*/
export function hasInterruptedStream(
messages: DisplayedMessage[],
pendingStreamStartTime: number | null = null
): boolean {
if (messages.length === 0) return false;

// Don't show retry barrier if user message was sent very recently (< 3s)
// Don't show retry barrier if user message was sent very recently (within the grace period)
// This prevents flash during normal send flow while stream-start event arrives
// After 3s, we assume something is wrong and show the barrier
// After the grace period, assume something is wrong and show the barrier
if (pendingStreamStartTime !== null) {
const elapsed = Date.now() - pendingStreamStartTime;
if (elapsed < 3000) return false;
if (elapsed < PENDING_STREAM_START_GRACE_PERIOD_MS) return false;
}

const lastMessage = messages[messages.length - 1];
Expand Down