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
7 changes: 4 additions & 3 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { computeTaskReportLinking } from "@/browser/utils/messages/taskReportLin
import { BashOutputCollapsedIndicator } from "@/browser/features/Tools/BashOutputCollapsedIndicator";
import {
getInterruptionContext,
getLastMainRetryCandidateMessage,
getLastNonDecorativeMessage,
} from "@/common/utils/messages/retryEligibility";
import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip";
Expand Down Expand Up @@ -818,10 +819,10 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
workspaceState.autoRetryStatus?.type === "auto-retry-scheduled" ||
workspaceState.autoRetryStatus?.type === "auto-retry-starting";

const lastActionableMessage = getLastNonDecorativeMessage(workspaceState.messages);
const lastRetryCandidateMessage = getLastMainRetryCandidateMessage(workspaceState.messages);
const suppressRetryBarrier =
lastActionableMessage?.type === "stream-error" &&
lastActionableMessage.errorType === "context_exceeded";
lastRetryCandidateMessage?.type === "stream-error" &&
lastRetryCandidateMessage.errorType === "context_exceeded";
const shouldMountRetryBarrier = !suppressRetryBarrier;
const showRetryBarrierUI = showRetryBarrier && !suppressRetryBarrier;

Expand Down
4 changes: 2 additions & 2 deletions src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AlertTriangle, RefreshCw } from "lucide-react";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { useAPI } from "@/browser/contexts/API";
import { useWorkspaceState } from "@/browser/stores/WorkspaceStore";
import { getLastNonDecorativeMessage } from "@/common/utils/messages/retryEligibility";
import { getLastMainRetryCandidateMessage } from "@/common/utils/messages/retryEligibility";
import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds";
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
Expand Down Expand Up @@ -239,7 +239,7 @@ export const RetryBarrier: React.FC<RetryBarrierProps> = (props) => {
void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false });
};

const lastMessage = getLastNonDecorativeMessage(workspaceState.messages);
const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages);
const lastStreamError = lastMessage?.type === "stream-error" ? lastMessage : null;
const interruptionReason = lastStreamError?.errorType === "rate_limit" ? "Rate limited" : null;
const isWaitingForInitialResponse =
Expand Down
8 changes: 0 additions & 8 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3976,14 +3976,6 @@ export class WorkspaceStore {
workspaceId: string,
aggregator: StreamingMessageAggregator,
data: WorkspaceChatMessage
): void {
this.dispatchProcessStreamEvent(workspaceId, aggregator, data);
}

private dispatchProcessStreamEvent(
workspaceId: string,
aggregator: StreamingMessageAggregator,
data: WorkspaceChatMessage
): void {
// Handle special events first
if (isStreamError(data)) {
Expand Down
3 changes: 2 additions & 1 deletion src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { trackCommandUsed } from "@/common/telemetry";
import { addEphemeralMessage } from "@/browser/stores/WorkspaceStore";
import { setGoalWithConflictRetry } from "@/browser/utils/goals/setGoalWithConflictRetry";
import { loadGoalDefaults, resolveGoalSetIntent } from "@/browser/utils/goals/resolveGoalSetIntent";
import { SIDE_QUESTION_COMMAND } from "@/common/utils/messages/sideQuestion";

const BUILT_IN_MODEL_SET = new Set<string>(Object.values(KNOWN_MODELS).map((model) => model.id));

Expand Down Expand Up @@ -591,7 +592,7 @@ export async function processSlashCommand(
return { clearInput: false, toastShown: true };
}
const workspaceId = context.workspaceId;
const rawCommand = `/btw ${parsed.question}`;
const rawCommand = `${SIDE_QUESTION_COMMAND} ${parsed.question}`;
const asyncCommandToken = context.asyncCommandToken;
const isCurrentSideQuestion = (): boolean =>
asyncCommandToken === undefined ||
Expand Down
89 changes: 89 additions & 0 deletions src/common/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,95 @@ describe("hasInterruptedStream", () => {
expect(hasInterruptedStream(messages, null)).toBe(true);
});

it("returns false for a trailing /btw side-question user row", () => {
const messages: DisplayedMessage[] = [
userMessage(),
assistantMessage(),
userMessage({
id: "side-question-1",
historyId: "side-question-1",
content: "what file were you editing?",
historySequence: 3,
isSideQuestion: true,
}),
];

expect(hasInterruptedStream(messages, null)).toBe(false);
expect(isEligibleForAutoRetry(messages, null)).toBe(false);
});

it("returns false for a trailing partial /btw side-answer row", () => {
const messages: DisplayedMessage[] = [
userMessage({
id: "side-question-1",
historyId: "side-question-1",
content: "what file were you editing?",
historySequence: 1,
isSideQuestion: true,
}),
assistantMessage({
id: "side-answer-1-0",
historyId: "side-answer-1",
content: "src/config.ts",
historySequence: 2,
isPartial: true,
isStreaming: true,
isSideAnswer: true,
}),
];

expect(hasInterruptedStream(messages, null)).toBe(false);
expect(isEligibleForAutoRetry(messages, null)).toBe(false);
});

it("ignores trailing /btw rows and still detects an earlier interrupted main response", () => {
const messages: DisplayedMessage[] = [
userMessage(),
assistantMessage({
id: "assistant-main-1",
historyId: "assistant-main-1",
content: "Partial main response",
historySequence: 2,
isPartial: true,
isStreaming: false,
}),
userMessage({
id: "side-question-1",
historyId: "side-question-1",
content: "what file were you editing?",
historySequence: 3,
isSideQuestion: true,
}),
assistantMessage({
id: "side-answer-1-0",
historyId: "side-answer-1",
content: "src/config.ts",
historySequence: 4,
isSideAnswer: true,
}),
];

expect(hasInterruptedStream(messages, null)).toBe(true);
expect(isEligibleForAutoRetry(messages, null)).toBe(true);
});

it("preserves stream-error retry classification before trailing /btw rows", () => {
const messages: DisplayedMessage[] = [
userMessage(),
streamErrorMessage({ errorType: "context_exceeded" }),
userMessage({
id: "side-question-1",
historyId: "side-question-1",
content: "what file were you editing?",
historySequence: 3,
isSideQuestion: true,
}),
];

expect(hasInterruptedStream(messages, null)).toBe(true);
expect(isEligibleForAutoRetry(messages, null)).toBe(false);
});

it("suppresses retry while runtime startup is still in progress", () => {
const messages: DisplayedMessage[] = [userMessage()];

Expand Down
34 changes: 32 additions & 2 deletions src/common/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ function isDecorativeTranscriptMessage(message: DisplayedMessage): boolean {
);
}

function isSideQuestionTranscriptMessage(message: DisplayedMessage): boolean {
return (
(message.type === "user" && message.isSideQuestion === true) ||
(message.type === "assistant" && message.isSideAnswer === true)
);
}

export function getLastNonDecorativeMessage(
messages: DisplayedMessage[]
): DisplayedMessage | undefined {
Expand All @@ -115,6 +122,24 @@ export function getLastNonDecorativeMessage(
return undefined;
}

/**
* Latest transcript row that belongs to the main-agent retry lifecycle.
* Decorative rows and /btw side-branch rows are persisted in the transcript but
* must not become the retry candidate for the main agent.
*/
export function getLastMainRetryCandidateMessage(
messages: DisplayedMessage[]
): DisplayedMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const candidate = messages[i];
if (isDecorativeTranscriptMessage(candidate) || isSideQuestionTranscriptMessage(candidate)) {
continue;
Comment thread
ammar-agent marked this conversation as resolved.
}
return candidate;
}
return undefined;
}

/**
* Check if messages contain an interrupted stream
*
Expand Down Expand Up @@ -145,7 +170,12 @@ function computeHasInterruptedStream(
if (elapsed < PENDING_STREAM_START_GRACE_PERIOD_MS) return false;
}

const lastMessage = getLastNonDecorativeMessage(messages);
// /btw rows are persisted into the transcript, but they are a read-only side
// branch that intentionally bypasses the main agent stream lifecycle. Ignore
// them when deciding whether the main agent has an interrupted stream: an idle
// /btw should not flash RetryBarrier, but a partial main-agent response before
// the aside must still be retryable after a reload/crash.
const lastMessage = getLastMainRetryCandidateMessage(messages);
if (!lastMessage) return false;

// Don't show retry barrier if workspace init is still running AND no error has occurred yet.
Expand Down Expand Up @@ -224,7 +254,7 @@ export function getInterruptionContext(
return { hasInterruptedStream: false, isEligibleForAutoRetry: false };
}

const lastMessage = getLastNonDecorativeMessage(messages);
const lastMessage = getLastMainRetryCandidateMessage(messages);
if (!lastMessage) {
return { hasInterruptedStream: false, isEligibleForAutoRetry: false };
}
Expand Down
9 changes: 9 additions & 0 deletions src/common/utils/messages/sideQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import type { MuxMessage, MuxMetadata } from "@/common/types/message";
export const SIDE_QUESTION_METADATA_TYPE = "side-question";
export const SIDE_QUESTION_ANSWER_METADATA_TYPE = "side-question-answer";

/**
* The user-facing slash-command literal that triggers a side question. Kept in
* one place so the backend (persisting the rendered user row) and the frontend
* (parsing input and restoring drafts on RPC failure) cannot drift apart.
*
* NOTE: includes no trailing space — render as `${SIDE_QUESTION_COMMAND} <q>`.
*/
export const SIDE_QUESTION_COMMAND = "/btw";

type SideQuestionMetadata = Extract<
NonNullable<MuxMetadata["muxMetadata"]>,
{ type: typeof SIDE_QUESTION_METADATA_TYPE }
Expand Down
70 changes: 60 additions & 10 deletions src/node/services/sideQuestionService.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import * as aiSdk from "ai";
import { type LanguageModel } from "ai";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { askSideQuestion, buildSideQuestionMessages } from "./sideQuestionService";
import {
askSideQuestion,
buildSideQuestionMessages,
type SideQuestionAIService,
} from "./sideQuestionService";
import type { AIService } from "./aiService";
import { Err, Ok } from "@/common/types/result";
import { createMuxMessage } from "@/common/types/message";
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
import type { WorkspaceChatMessage } from "@/common/orpc/types";
import { CONTEXT_BOUNDARY_KINDS } from "@/common/utils/messages/compactionBoundary";
import { createTestHistoryService } from "./testHistoryService";
Expand Down Expand Up @@ -34,11 +38,11 @@ function createFakeModel(modelId = "side-question-model"): LanguageModel {
function createFakeAIService(
model: LanguageModel,
streamInfo?: ReturnType<AIService["getStreamInfo"]>
): AIService {
): SideQuestionAIService {
return {
createModel: () => Promise.resolve(Ok(model)),
getStreamInfo: () => streamInfo,
} as unknown as AIService;
};
}

/** Fake textStream that emits a fixed sequence of chunks. */
Expand All @@ -65,14 +69,16 @@ describe("buildSideQuestionMessages", () => {
"User: change the config\nAssistant: edited src/config.ts"
);

expect(system).toContain("No tools are available");
expect(system).toContain("clearly-marked side answer");
// We deliberately do NOT assert the prompt's wording here — that would be
// a tautology against the literal in `buildSideQuestionMessages`. The
// tools-denied contract is exercised behaviorally below (the streamText
// call asserts `receivedTools === undefined`). What matters structurally
// is: exactly one user message, the question shows through, and the
// transcript we passed in is present so the model can ground on it.
expect(typeof system).toBe("string");
expect(messages).toHaveLength(1);
const content = messages[0]?.content as string;
expect(content).toContain("<system-reminder>");
expect(content).toContain("/btw");
expect(content).toContain("No tools are available");
expect(content).toContain("Side question: what file did you just edit?");
expect(content).toContain("what file did you just edit?");
expect(content).toContain("Assistant: edited src/config.ts");
});

Expand Down Expand Up @@ -268,6 +274,50 @@ describe("askSideQuestion (persisted, streaming)", () => {
}
});

test("keeps legacy tool-* parts visible and skips malformed parts in the transcript", async () => {
const { historyService, cleanup } = await createTestHistoryService();
try {
const workspaceId = "ws-btw-legacy-tool-part";
await historyService.appendToHistory(
workspaceId,
createMuxMessage("u1", "user", "run tests")
);
const legacyAssistant = {
id: "a-legacy-tool",
role: "assistant" as const,
metadata: { timestamp: 1 },
parts: [null, { type: 42 }, { type: "tool-bash" as const }],
};
// Older or hand-edited chat.jsonl rows can still contain AI SDK
// `tool-<name>` parts (or malformed junk) even though current
// `MuxMessage` types only model `dynamic-tool`. Preserve that legacy
// shape here so /btw transcript generation stays upgrade-safe and
// self-healing.
await historyService.appendToHistory(workspaceId, legacyAssistant as unknown as MuxMessage);

let capturedPrompt: string | undefined;
spyOn(aiSdk, "streamText").mockImplementation(((opts: unknown) => {
const messages = (opts as { messages: Array<{ content: string }> }).messages;
capturedPrompt = messages[0]?.content;
return fakeTextStream(["ok"]);
}) as unknown as typeof aiSdk.streamText);

const result = await askSideQuestion({
workspaceId,
question: "what command just ran?",
candidates: ["openai:gpt-4.1-mini"],
aiService: createFakeAIService(createFakeModel()),
historyService,
emitChatEvent: () => undefined,
});

expect(result.success).toBe(true);
expect(capturedPrompt).toContain("[tool bash]");
} finally {
await cleanup();
}
});

test("keeps recent main-chat context even after many prior /btw rows", async () => {
const { historyService, cleanup } = await createTestHistoryService();
try {
Expand Down
Loading
Loading