From bb5d61bd02491a63948dfe69be59a05b2be57cf5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:10:49 +0000 Subject: [PATCH 1/5] refactor(chat): share queued composer controls --- frontend/src/components/AgentMode.tsx | 78 ++++----------- .../chat/QueuedComposerMessages.test.tsx | 78 +++++++++++++++ .../chat/QueuedComposerMessages.tsx | 95 +++++++++++++++++++ .../src/services/agentComposerQueue.test.ts | 22 +++++ frontend/src/services/agentComposerQueue.ts | 47 +++++++-- frontend/src/services/composerQueue.test.ts | 78 +++++++++++++++ frontend/src/services/composerQueue.ts | 45 +++++++++ 7 files changed, 373 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/chat/QueuedComposerMessages.test.tsx create mode 100644 frontend/src/components/chat/QueuedComposerMessages.tsx create mode 100644 frontend/src/services/composerQueue.test.ts create mode 100644 frontend/src/services/composerQueue.ts diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 37fddd6cc..fb3e58294 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -59,6 +59,11 @@ import { ChatUserTurn } from "@/components/chat/ChatTurn"; import { ChatCopyButton } from "@/components/chat/ChatCopyButton"; +import { + DiscardQueuedMessageEditButton, + QUEUED_MESSAGE_EDIT_PLACEHOLDER, + QueuedComposerMessages +} from "@/components/chat/QueuedComposerMessages"; import { continueChatComposerList, continueChatComposerListBeforeInput @@ -5062,55 +5067,16 @@ function AgentComposer({ {isExpanded ? : } ) : null} - {queuedMessages.length > 0 ? ( -
- {queuedMessages.map((item) => ( -
- - {item.text || `${item.attachments?.length ?? 0} image attachment(s)`} - - {onCancelQueuedMessage ? ( - - ) : null} - {onEditQueuedMessage ? ( - - ) : null} - {onSteerQueuedMessage ? ( - - ) : null} -
- ))} -
- ) : null} + `${item.attachments?.length ?? 0} image attachment(s)`} + onRemove={onCancelQueuedMessage} + onEdit={onEditQueuedMessage} + onSendNow={onSteerQueuedMessage} + sendNowDisabled={isSendDisabled} + /> {!editingQueueId && draftImages.length > 0 ? (
{draftImages.map((image, index) => ( @@ -5149,9 +5115,7 @@ function AgentComposer({ onPaste={onImagePaste} disabled={isSendDisabled} placeholder={ - editingQueueId - ? "Edit the queued message, then send to keep its place..." - : "Ask Maple to work in this folder..." + editingQueueId ? QUEUED_MESSAGE_EDIT_PLACEHOLDER : "Ask Maple to work in this folder..." } className={cn( CHAT_COMPOSER_TEXTAREA_CLASS, @@ -5234,15 +5198,7 @@ function AgentComposer({
{editingQueueId && onDiscardQueuedMessageEdit ? ( - + ) : null} {agentComposerShowsStop(isSending) ? ( "); + }); + + test("renders nothing for an empty queue", () => { + expect(renderToStaticMarkup()).toBe(""); + }); +}); diff --git a/frontend/src/components/chat/QueuedComposerMessages.tsx b/frontend/src/components/chat/QueuedComposerMessages.tsx new file mode 100644 index 000000000..a02b7c3ad --- /dev/null +++ b/frontend/src/components/chat/QueuedComposerMessages.tsx @@ -0,0 +1,95 @@ +import { ArrowUp, FilePenLine, Trash } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { QueuedComposerMessage } from "@/services/composerQueue"; +import { cn } from "@/utils/utils"; + +export type { QueuedComposerMessage } from "@/services/composerQueue"; + +export const QUEUED_MESSAGE_EDIT_PLACEHOLDER = + "Edit the queued message, then send to keep its place..."; + +export function QueuedComposerMessages({ + items, + className, + editingQueueId = null, + getFallbackLabel, + onRemove, + onEdit, + onSendNow, + sendNowDisabled = false +}: { + items: readonly T[]; + className?: string; + editingQueueId?: string | null; + getFallbackLabel?: (item: T) => string; + onRemove?: (queueId: string) => void; + onEdit?: (queueId: string) => void; + onSendNow?: (queueId: string) => void; + sendNowDisabled?: boolean; +}) { + if (items.length === 0) return null; + + return ( +
+ {items.map((item) => ( +
+ + {item.text || getFallbackLabel?.(item) || "Queued message"} + + {onRemove ? ( + + ) : null} + {onEdit ? ( + + ) : null} + {onSendNow ? ( + + ) : null} +
+ ))} +
+ ); +} + +export function DiscardQueuedMessageEditButton({ onDiscard }: { onDiscard: () => void }) { + return ( + + ); +} diff --git a/frontend/src/services/agentComposerQueue.test.ts b/frontend/src/services/agentComposerQueue.test.ts index 1e10b107c..4d2594157 100644 --- a/frontend/src/services/agentComposerQueue.test.ts +++ b/frontend/src/services/agentComposerQueue.test.ts @@ -101,6 +101,28 @@ describe("agent composer queue projection", () => { expect(queuedMessageEditStillPresent(second!.edit, [queued("q1", "oldest")])).toBe(false); }); + test("preserves the Agent session-keyed edit API over the shared helper", () => { + const next = beginQueuedMessageEdit({ + current: { + sessionId: "session-1", + queueId: "q1", + stashedDraft: "session one draft" + }, + sessionId: "session-2", + item: queued("q2", "session two queued"), + composerText: "session two draft" + }); + + expect(next).toEqual({ + edit: { + sessionId: "session-2", + queueId: "q2", + stashedDraft: "session two draft" + }, + composer: "session two queued" + }); + }); + test("does not seed thought tracking for a staged follow-up", () => { expect(shouldPrepareThoughtAfterAgentSend(undefined)).toBe(true); expect(shouldPrepareThoughtAfterAgentSend(null)).toBe(true); diff --git a/frontend/src/services/agentComposerQueue.ts b/frontend/src/services/agentComposerQueue.ts index f14739e68..5922cfa36 100644 --- a/frontend/src/services/agentComposerQueue.ts +++ b/frontend/src/services/agentComposerQueue.ts @@ -1,3 +1,9 @@ +import { + beginQueuedMessageEdit as beginSharedQueuedMessageEdit, + discardQueuedMessageEdit as discardSharedQueuedMessageEdit, + queuedMessageEditStillPresent as sharedQueuedMessageEditStillPresent +} from "./composerQueue"; + export interface AgentQueuedMessage { queueId: string; messageId: string; @@ -59,28 +65,51 @@ export function beginQueuedMessageEdit({ item: AgentQueuedMessage; composerText: string; }): { edit: AgentQueuedMessageEdit; composer: string } | null { - if (current?.sessionId === sessionId && current.queueId === item.queueId) { - return null; - } + const result = beginSharedQueuedMessageEdit({ + current: current + ? { + scopeKey: current.sessionId, + queueId: current.queueId, + stashedDraft: current.stashedDraft + } + : null, + scopeKey: sessionId, + item, + composerText + }); + if (!result) return null; return { edit: { - sessionId, - queueId: item.queueId, - stashedDraft: current?.sessionId === sessionId ? current.stashedDraft : composerText + sessionId: result.edit.scopeKey, + queueId: result.edit.queueId, + stashedDraft: result.edit.stashedDraft }, - composer: item.text + composer: result.composer }; } export function discardQueuedMessageEdit(edit: AgentQueuedMessageEdit): string { - return edit.stashedDraft; + return discardSharedQueuedMessageEdit({ + scopeKey: edit.sessionId, + queueId: edit.queueId, + stashedDraft: edit.stashedDraft + }); } export function queuedMessageEditStillPresent( edit: AgentQueuedMessageEdit | null, items: AgentQueuedMessage[] ): boolean { - return Boolean(edit && items.some((item) => item.queueId === edit.queueId)); + return sharedQueuedMessageEditStillPresent( + edit + ? { + scopeKey: edit.sessionId, + queueId: edit.queueId, + stashedDraft: edit.stashedDraft + } + : null, + items + ); } export function shouldPrepareThoughtAfterAgentSend(queued?: AgentQueuedMessage | null): boolean { diff --git a/frontend/src/services/composerQueue.test.ts b/frontend/src/services/composerQueue.test.ts new file mode 100644 index 000000000..bdb8f22ff --- /dev/null +++ b/frontend/src/services/composerQueue.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { + beginQueuedMessageEdit, + discardQueuedMessageEdit, + queuedMessageEditStillPresent, + type QueuedComposerMessage +} from "./composerQueue"; + +function queued(queueId: string, text: string): QueuedComposerMessage { + return { queueId, text }; +} + +describe("shared composer queue editing", () => { + test("starts an in-place edit and keeps an unpublished draft stashed", () => { + const started = beginQueuedMessageEdit({ + current: null, + scopeKey: "scope-1", + item: queued("q1", "oldest"), + composerText: "new draft" + }); + + expect(started).toEqual({ + edit: { scopeKey: "scope-1", queueId: "q1", stashedDraft: "new draft" }, + composer: "oldest" + }); + expect(discardQueuedMessageEdit(started!.edit)).toBe("new draft"); + }); + + test("switching items in one scope keeps the original draft and does not restack", () => { + const first = beginQueuedMessageEdit({ + current: null, + scopeKey: "scope-1", + item: queued("q1", "oldest"), + composerText: "new draft" + }); + const second = beginQueuedMessageEdit({ + current: first!.edit, + scopeKey: "scope-1", + item: queued("q2", "middle"), + composerText: "oldest" + }); + + expect(second).toEqual({ + edit: { scopeKey: "scope-1", queueId: "q2", stashedDraft: "new draft" }, + composer: "middle" + }); + expect( + beginQueuedMessageEdit({ + current: second!.edit, + scopeKey: "scope-1", + item: queued("q2", "middle"), + composerText: "middle" + }) + ).toBeNull(); + expect( + queuedMessageEditStillPresent(second!.edit, [queued("q1", "oldest"), queued("q2", "middle")]) + ).toBe(true); + expect(queuedMessageEditStillPresent(second!.edit, [queued("q1", "oldest")])).toBe(false); + }); + + test("a different scope stashes that scope's current draft", () => { + const next = beginQueuedMessageEdit({ + current: { + scopeKey: "scope-1", + queueId: "q1", + stashedDraft: "scope one draft" + }, + scopeKey: "scope-2", + item: queued("q1", "scope two queued"), + composerText: "scope two draft" + }); + + expect(next).toEqual({ + edit: { scopeKey: "scope-2", queueId: "q1", stashedDraft: "scope two draft" }, + composer: "scope two queued" + }); + }); +}); diff --git a/frontend/src/services/composerQueue.ts b/frontend/src/services/composerQueue.ts new file mode 100644 index 000000000..131346100 --- /dev/null +++ b/frontend/src/services/composerQueue.ts @@ -0,0 +1,45 @@ +export interface QueuedComposerMessage { + queueId: string; + text: string; +} + +export interface QueuedMessageEdit { + scopeKey: string; + queueId: string; + stashedDraft: string; +} + +export function beginQueuedMessageEdit({ + current, + scopeKey, + item, + composerText +}: { + current: QueuedMessageEdit | null; + scopeKey: string; + item: QueuedComposerMessage; + composerText: string; +}): { edit: QueuedMessageEdit; composer: string } | null { + if (current?.scopeKey === scopeKey && current.queueId === item.queueId) { + return null; + } + return { + edit: { + scopeKey, + queueId: item.queueId, + stashedDraft: current?.scopeKey === scopeKey ? current.stashedDraft : composerText + }, + composer: item.text + }; +} + +export function discardQueuedMessageEdit(edit: QueuedMessageEdit): string { + return edit.stashedDraft; +} + +export function queuedMessageEditStillPresent( + edit: QueuedMessageEdit | null, + items: readonly Pick[] +): boolean { + return Boolean(edit && items.some((item) => item.queueId === edit.queueId)); +} From c31991e2488692b0ffeffc7238d1e330e5076ba5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:12:11 +0000 Subject: [PATCH 2/5] fix(sdk): bind requests to the initiating account --- frontend/src/ai/OpenAIContext.tsx | 9 +- .../services/chatAccountCredential.test.ts | 85 ++++ .../src/services/chatAccountCredential.ts | 89 ++++ sdk/src/lib/ai.ts | 225 +++++++--- sdk/src/lib/api.ts | 88 ++-- sdk/src/lib/credentialIdentity.ts | 147 +++++++ sdk/src/lib/encryptedApi.ts | 70 +++- sdk/src/lib/index.ts | 7 +- sdk/src/lib/main.tsx | 54 ++- sdk/src/lib/test/credentialIdentity.test.ts | 204 +++++++++ sdk/src/lib/test/customFetch.test.ts | 396 +++++++++++++++++- sdk/src/lib/test/encryptedApi.test.ts | 247 ++++++++++- .../integration/platformPushSettings.test.ts | 3 +- sdk/src/lib/test/integration/web.test.ts | 3 +- sdk/src/lib/test/models.test.ts | 11 +- sdk/src/lib/test/utils.ts | 6 + 16 files changed, 1517 insertions(+), 127 deletions(-) create mode 100644 frontend/src/services/chatAccountCredential.test.ts create mode 100644 frontend/src/services/chatAccountCredential.ts create mode 100644 sdk/src/lib/credentialIdentity.ts create mode 100644 sdk/src/lib/test/credentialIdentity.test.ts diff --git a/frontend/src/ai/OpenAIContext.tsx b/frontend/src/ai/OpenAIContext.tsx index 3cead47cb..9d6f1b0a8 100644 --- a/frontend/src/ai/OpenAIContext.tsx +++ b/frontend/src/ai/OpenAIContext.tsx @@ -1,5 +1,6 @@ import OpenAI from "openai"; import { useOpenSecret } from "@opensecret/react"; +import { createAccountBoundChatFetch } from "@/services/chatAccountCredential"; import { OpenAIContext } from "./OpenAIContextDef"; export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => { @@ -8,7 +9,7 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => { throw new Error("VITE_OPEN_SECRET_API_URL must be set"); } - const { aiCustomFetch } = useOpenSecret(); + const { aiCustomFetch, auth } = useOpenSecret(); const access_token = window.localStorage.getItem("access_token"); // If we're not logged in we can't set up openai @@ -24,7 +25,11 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => { defaultHeaders: { "Accept-Encoding": "identity" }, - fetch: aiCustomFetch, + fetch: createAccountBoundChatFetch({ + expectedUserId: auth.user?.user.id, + getAccessToken: () => window.localStorage.getItem("access_token"), + fetch: aiCustomFetch + }), maxRetries: 0 // Disable automatic retries }); diff --git a/frontend/src/services/chatAccountCredential.test.ts b/frontend/src/services/chatAccountCredential.test.ts new file mode 100644 index 000000000..324517156 --- /dev/null +++ b/frontend/src/services/chatAccountCredential.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE, + ChatAccountCredentialMismatchError, + assertChatAccountCredential, + chatAccessTokenSubject, + createAccountBoundChatFetch, + isChatAccountCredentialMismatchError +} from "./chatAccountCredential"; + +function tokenForSubject(subject: string): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode({ sub: subject })}.signature`; +} + +describe("account-bound Chat credentials", () => { + test("extracts a JWT subject and rejects malformed credentials", () => { + expect(chatAccessTokenSubject(tokenForSubject("user-a"))).toBe("user-a"); + expect(chatAccessTokenSubject("not-a-jwt")).toBeNull(); + expect(chatAccessTokenSubject(null)).toBeNull(); + }); + + test("allows refreshed tokens for the same account", async () => { + let token = tokenForSubject("user-a"); + const calls: string[] = []; + const fetch = createAccountBoundChatFetch({ + expectedUserId: "user-a", + getAccessToken: () => token, + fetch: async (input) => { + calls.push(String(input)); + return Response.json({ ok: true }); + } + }); + + await fetch("https://example.test/first"); + token = tokenForSubject("user-a"); + await fetch("https://example.test/after-refresh"); + + expect(calls).toEqual(["https://example.test/first", "https://example.test/after-refresh"]); + }); + + test("blocks a replaced account before invoking the transport", async () => { + let called = false; + const fetch = createAccountBoundChatFetch({ + expectedUserId: "user-a", + getAccessToken: () => tokenForSubject("user-b"), + fetch: async () => { + called = true; + return Response.json({ ok: true }); + } + }); + + try { + await fetch("https://example.test/blocked"); + throw new Error("expected account-bound fetch to reject"); + } catch (error) { + expect(isChatAccountCredentialMismatchError(error)).toBe(true); + expect(error).toMatchObject({ + code: CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE, + requestDispatchCode: "opensecret_request_not_dispatched", + definitelyNotDispatched: true + }); + } + expect(called).toBe(false); + }); + + test("guards non-Chat account operations with the same account identity", () => { + expect(() => + assertChatAccountCredential("user-a", () => tokenForSubject("user-a")) + ).not.toThrow(); + expect(() => assertChatAccountCredential("user-a", () => tokenForSubject("user-b"))).toThrow( + ChatAccountCredentialMismatchError + ); + expect(() => assertChatAccountCredential(undefined, () => tokenForSubject("user-a"))).toThrow( + ChatAccountCredentialMismatchError + ); + }); + + test("recognizes an OpenAI-style wrapped mismatch", () => { + const mismatch = { code: CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE }; + expect(isChatAccountCredentialMismatchError({ cause: mismatch })).toBe(true); + expect(isChatAccountCredentialMismatchError(new Error("network"))).toBe(false); + }); +}); diff --git a/frontend/src/services/chatAccountCredential.ts b/frontend/src/services/chatAccountCredential.ts new file mode 100644 index 000000000..698d217f2 --- /dev/null +++ b/frontend/src/services/chatAccountCredential.ts @@ -0,0 +1,89 @@ +export const CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE = "chat_account_credential_mismatch"; +const REQUEST_NOT_DISPATCHED_CODE = "opensecret_request_not_dispatched"; + +type AccountBoundFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return atob(padded); +} + +export function chatAccessTokenSubject(accessToken: string | null): string | null { + if (!accessToken) return null; + try { + const parts = accessToken.split("."); + if (parts.length !== 3 || !parts[1]) return null; + const payload = JSON.parse(decodeBase64Url(parts[1])) as { sub?: unknown }; + return typeof payload.sub === "string" && payload.sub ? payload.sub : null; + } catch { + return null; + } +} + +export class ChatAccountCredentialMismatchError extends Error { + readonly code = CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE; + + constructor() { + super("The authenticated Chat account changed before this request could start"); + this.name = "ChatAccountCredentialMismatchError"; + } +} + +/** + * Fails a user-scoped operation closed when another tab has replaced the + * browser credentials since this React tree was rendered. + */ +export function assertChatAccountCredential( + expectedUserId: string | undefined, + getAccessToken: () => string | null = () => window.localStorage.getItem("access_token") +): void { + if (!expectedUserId || chatAccessTokenSubject(getAccessToken()) !== expectedUserId) { + throw new ChatAccountCredentialMismatchError(); + } +} + +/** + * Binds every Chat network request to the account that created its OpenAI + * client. Access-token refreshes remain valid because the stable JWT subject, + * rather than the token bytes, is compared. Cross-tab account replacement + * fails before plaintext is handed to the encrypted transport. + */ +export function createAccountBoundChatFetch({ + expectedUserId, + getAccessToken, + fetch +}: { + expectedUserId: string | undefined; + getAccessToken: () => string | null; + fetch: AccountBoundFetch; +}): AccountBoundFetch { + return (input, init) => { + try { + assertChatAccountCredential(expectedUserId, getAccessToken); + } catch (error) { + if (typeof error === "object" && error !== null) { + Object.assign(error, { + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + } + return Promise.reject(error); + } + return fetch(input, init); + }; +} + +export function isChatAccountCredentialMismatchError(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 3 && current && typeof current === "object"; depth += 1) { + if ( + current instanceof ChatAccountCredentialMismatchError || + ("code" in current && current.code === CHAT_ACCOUNT_CREDENTIAL_MISMATCH_CODE) + ) { + return true; + } + current = "cause" in current ? current.cause : undefined; + } + return false; +} diff --git a/sdk/src/lib/ai.ts b/sdk/src/lib/ai.ts index f6399b79b..f002cd42a 100644 --- a/sdk/src/lib/ai.ts +++ b/sdk/src/lib/ai.ts @@ -2,7 +2,55 @@ import { decryptMessage, encryptMessage } from "./encryption"; import { getAttestation, type Attestation } from "./getAttestation"; import * as api from "./api"; import { serializePcrConfig, snapshotPcrConfig, type PcrConfig } from "./pcr"; -import { classifyRecovery } from "./recovery"; +import { classifyRecovery, ERROR_CODE_HEADER, ERROR_CONTRACT_HEADER } from "./recovery"; +import { + ACCOUNT_CREDENTIAL_MISMATCH_CODE, + accessTokenSubject, + accountCredentialMismatchError, + isAccountCredentialMismatchError +} from "./credentialIdentity"; + +export { ACCOUNT_CREDENTIAL_MISMATCH_CODE } from "./credentialIdentity"; + +/** Identifies a failure that occurred before the target fetch was invoked. */ +export const REQUEST_NOT_DISPATCHED_CODE = "opensecret_request_not_dispatched"; +const ERROR_CONTRACT_VERSION = "1"; +const IMAGE_DESCRIPTION_UNAVAILABLE_ERROR_CODE = "image_description_unavailable"; +const IMAGE_DESCRIPTION_UNAVAILABLE_STATUS = 503; + +/** Orthogonal dispatch metadata that preserves the source error's code and name. */ +export interface RequestNotDispatchedMarker { + readonly requestDispatchCode: typeof REQUEST_NOT_DISPATCHED_CODE; + readonly definitelyNotDispatched: true; +} + +function markRequestNotDispatched(error: unknown): unknown & RequestNotDispatchedMarker { + const marker: RequestNotDispatchedMarker = { + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }; + + if ((typeof error === "object" && error !== null) || typeof error === "function") { + try { + // Errors and DOMExceptions are normally extensible. Tagging the original + // preserves credential codes, AbortError names, prototypes, and identity. + return Object.assign(error, marker); + } catch { + // Fall through for frozen or host-provided exception objects. + } + } + + const wrapped = Object.assign( + new Error(error instanceof Error ? error.message : "Request failed before transport dispatch"), + { cause: error }, + marker + ) as Error & { code?: unknown } & RequestNotDispatchedMarker; + if (typeof error === "object" && error !== null) { + if ("name" in error && typeof error.name === "string") wrapped.name = error.name; + if ("code" in error) wrapped.code = error.code; + } + return wrapped; +} export interface CustomFetchOptions { /** Optional API key to use instead of a JWT token. */ @@ -11,6 +59,8 @@ export interface CustomFetchOptions { apiUrl?: string; /** PCR0 trust policy enforced before non-loopback session key exchange; defaults to production. */ pcrConfig?: PcrConfig; + /** Optional user ID that every JWT attempt and retry must retain. */ + expectedUserId?: string; } interface ActiveAttestation { @@ -197,7 +247,16 @@ export function createCustomFetchWithDependencies( // flight; recovery must not switch between API-key and JWT credentials. const apiKey = options?.apiKey; const usesApiKey = Boolean(apiKey); + const expectedUserId = options?.expectedUserId; + let requestAcceptanceAmbiguous = false; + const assertExpectedAccount = () => { + if (!expectedUserId) return; + if (accessTokenSubject(window.localStorage.getItem("access_token")) !== expectedUserId) { + throw accountCredentialMismatchError(); + } + }; const getAuthHeader = () => { + assertExpectedAccount(); // If an API key is provided, use it instead of JWT token if (apiKey) { return `Bearer ${apiKey}`; @@ -223,6 +282,10 @@ export function createCustomFetchWithDependencies( throwIfAborted(request.signal); const makeRequest = async (attestation: ActiveAttestation) => { + // Attestation and request snapshots can yield. Re-check immediately + // before each network attempt so an account replacement cannot replay + // retained plaintext under another user's credential. + assertExpectedAccount(); const headers = new Headers(request.headers); headers.set("Authorization", authHeader); headers.set("x-session-id", attestation.sessionId); @@ -243,10 +306,17 @@ export function createCustomFetchWithDependencies( headers.set("Content-Type", "application/json"); } - return { - attestation, - response: await dependencies.fetch(request.url, requestOptions) - }; + // Encryption can be substantial for image-bearing requests, and another + // tab can replace browser credentials while this synchronous work runs. + // Close that preparation window before handing the request to fetch. + assertExpectedAccount(); + + // Flip this immediately before invocation: synchronous throws and + // rejected fetch promises are both ambiguous because the transport was + // asked to dispatch. Only earlier failures are safe to replay. + requestAcceptanceAmbiguous = true; + const response = await dependencies.fetch(request.url, requestOptions); + return { attestation, response }; }; let attestation = requireActiveAttestation( @@ -262,12 +332,20 @@ export function createCustomFetchWithDependencies( while (true) { const attempt = await makeRequest(attestation); + assertExpectedAccount(); const recovery = classifyRecovery(attempt.response.status, attempt.response.headers); if (recovery === "refresh_access_token" && !usesApiKey && !replayed) { replayed = true; + // This HTTP response definitively rejected the outer request. Any + // failure while discarding it, refreshing credentials, or preparing + // the retry remains safe for the caller to restore. The next fetch + // invocation makes acceptance ambiguous again. + requestAcceptanceAmbiguous = false; await discardResponse(attempt.response); throwIfAborted(request.signal); + // Do not consume or rotate a replacement account's refresh token. + assertExpectedAccount(); console.warn("Unauthorized, refreshing access token"); await dependencies.refreshToken(); throwIfAborted(request.signal); @@ -287,6 +365,7 @@ export function createCustomFetchWithDependencies( if (recovery === "renew_session" && !replayed) { replayed = true; + requestAcceptanceAmbiguous = false; await discardResponse(attempt.response); throwIfAborted(request.signal); console.warn("Bad Request, renewing attestation and retrying once"); @@ -303,7 +382,25 @@ export function createCustomFetchWithDependencies( const { sessionKey } = finalAttempt.attestation; if (!response.ok) { + // OpenSecret's non-timeout 4xx responses and explicit image-description + // failure reject a Responses turn before persistence. Record that fact + // before reading a possibly truncated error body so callers never wait + // for response ownership that cannot exist. The generic contract header + // only versions the error schema; it is not proof that a 5xx happened + // before persistence. + const isImageDescriptionPreAcceptanceError = + response.status === IMAGE_DESCRIPTION_UNAVAILABLE_STATUS && + response.headers.get(ERROR_CONTRACT_HEADER) === ERROR_CONTRACT_VERSION && + response.headers.get(ERROR_CODE_HEADER) === IMAGE_DESCRIPTION_UNAVAILABLE_ERROR_CODE; + if ( + response.status !== 408 && + ((response.status >= 400 && response.status < 500) || + isImageDescriptionPreAcceptanceError) + ) { + requestAcceptanceAmbiguous = false; + } const errorText = await response.text(); + assertExpectedAccount(); console.error( "Request failed with response status:", response.status, @@ -327,55 +424,75 @@ export function createCustomFetchWithDependencies( let buffer = ""; const stream = new ReadableStream({ async start(controller) { - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - - const chunk = decoder.decode(value); - buffer += chunk; - - let event; - while ((event = extractEvent(buffer))) { - buffer = buffer.slice(event.length); - - // Split the event into individual lines - const lines = event.split("\n"); - - for (const line of lines) { - // Handle event: lines - pass them through as-is - if (line.trim().startsWith("event: ")) { - controller.enqueue(line + "\n"); - } - // Handle data: lines - decrypt them - else if (line.trim().startsWith("data: ")) { - const data = line.slice(6).trim(); - if (data === "[DONE]") { - controller.enqueue(`data: [DONE]\n\n`); - } else { - try { - const decrypted = dependencies.decryptMessage(sessionKey, data); - - // Always enqueue the decrypted data - // Note: We don't add \n\n here because the empty line will be added separately - controller.enqueue(`data: ${decrypted}\n`); - } catch (error) { - console.error("Decryption error:", error, "Data:", data); - // Instead of sending the encrypted data, we'll skip this chunk - console.log("Skipping corrupted chunk"); + try { + while (true) { + const { done, value } = await reader!.read(); + assertExpectedAccount(); + if (done) break; + + const chunk = decoder.decode(value); + buffer += chunk; + + let event; + while ((event = extractEvent(buffer))) { + buffer = buffer.slice(event.length); + + // Split the event into individual lines + const lines = event.split("\n"); + + for (const line of lines) { + // Handle event: lines - pass them through as-is + if (line.trim().startsWith("event: ")) { + assertExpectedAccount(); + controller.enqueue(line + "\n"); + } + // Handle data: lines - decrypt them + else if (line.trim().startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data === "[DONE]") { + assertExpectedAccount(); + controller.enqueue(`data: [DONE]\n\n`); + } else { + try { + const decrypted = dependencies.decryptMessage(sessionKey, data); + + // Always enqueue the decrypted data + // Note: We don't add \n\n here because the empty line will be added separately + assertExpectedAccount(); + controller.enqueue(`data: ${decrypted}\n`); + } catch (error) { + if (isAccountCredentialMismatchError(error)) throw error; + console.error("Decryption error:", error, "Data:", data); + // Instead of sending the encrypted data, we'll skip this chunk + console.log("Skipping corrupted chunk"); + } } } - } - // Pass through empty lines - else if (line === "") { - controller.enqueue("\n"); + // Pass through empty lines + else if (line === "") { + assertExpectedAccount(); + controller.enqueue("\n"); + } } } } + assertExpectedAccount(); + controller.close(); + } catch (error) { + try { + await reader?.cancel(error); + } catch { + // The upstream body may already be closed or errored. + } + controller.error(error); } - controller.close(); + }, + async cancel(reason) { + await reader?.cancel(reason); } }); + assertExpectedAccount(); return new Response(stream, { headers: response.headers, status: response.status, @@ -385,6 +502,7 @@ export function createCustomFetchWithDependencies( // Decrypt regular JSON responses const responseText = await response.text(); + assertExpectedAccount(); try { const responseData = JSON.parse(responseText); @@ -423,36 +541,45 @@ export function createCustomFetchWithDependencies( headersOut.delete("content-length"); headersOut.delete("transfer-encoding"); + assertExpectedAccount(); return new Response(bytes, { headers: headersOut, status: response.status, statusText: response.statusText }); } - } catch { + } catch (error) { + if (isAccountCredentialMismatchError(error)) throw error; // Not JSON, continue with regular text response } // Return a new Response with the decrypted data + assertExpectedAccount(); return new Response(decrypted, { headers: response.headers, status: response.status, statusText: response.statusText }); } - } catch { + } catch (error) { + if (isAccountCredentialMismatchError(error)) throw error; // If it's not JSON or doesn't have encrypted field, return original response console.log("Response is not encrypted JSON, returning as-is"); } // Return the original response text as a new Response + assertExpectedAccount(); return new Response(responseText, { headers: response.headers, status: response.status, statusText: response.statusText }); } catch (error) { - console.error("Error during fetch process:", error); - throw error; + // Keep the original error code/name intact and add orthogonal dispatch + // metadata only when no transport invocation occurred in this logical + // call. Once fetch is invoked, failure remains deliberately ambiguous. + const reportedError = !requestAcceptanceAmbiguous ? markRequestNotDispatched(error) : error; + console.error("Error during fetch process:", reportedError); + throw reportedError; } }; } diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index cb41a9f2a..08ebb9e2a 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -2,6 +2,7 @@ import { encode } from "@stablelib/base64"; import { authenticatedApiCall, encryptedApiCall, openAiAuthenticatedApiCall } from "./encryptedApi"; import type { Model } from "openai/resources/models.js"; import { snapshotPcrConfig, type PcrConfig } from "./pcr"; +import { commitRefreshedUserTokensIfCurrent } from "./credentialIdentity"; let apiUrl = ""; let apiPcrConfig: PcrConfig = snapshotPcrConfig(); @@ -144,8 +145,11 @@ export async function refreshToken(): Promise { "Failed to refresh token" ); - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); + commitRefreshedUserTokensIfCurrent({ + initiatingRefreshToken: refresh_token, + accessToken: response.access_token, + refreshToken: response.refresh_token + }); return response; } catch (error) { console.error("Error refreshing token:", error); @@ -171,21 +175,23 @@ export async function fetchPut(key: string, value: string): Promise { ); } -export async function fetchDelete(key: string): Promise { +export async function fetchDelete(key: string, expectedUserId?: string): Promise { return authenticatedApiCall( `${apiUrl}/protected/kv/${key}`, "DELETE", undefined, - "Failed to delete key-value pair" + "Failed to delete key-value pair", + expectedUserId ); } -export async function fetchDeleteAllKV(): Promise { +export async function fetchDeleteAllKV(expectedUserId?: string): Promise { return authenticatedApiCall( `${apiUrl}/protected/kv`, "DELETE", undefined, - "Failed to delete all key-value pairs" + "Failed to delete all key-value pairs", + expectedUserId ); } @@ -1077,7 +1083,10 @@ export async function decryptData( * 3. The email contains a confirmation code that will be needed for confirmation * 4. The client must store the plaintext secret for confirmation */ -export async function requestAccountDeletion(hashedSecret: string): Promise { +export async function requestAccountDeletion( + hashedSecret: string, + expectedUserId?: string +): Promise { const deleteData = { hashed_secret: hashedSecret }; @@ -1085,7 +1094,8 @@ export async function requestAccountDeletion(hashedSecret: string): Promise { const confirmData = { confirmation_code: confirmationCode, @@ -1114,7 +1125,8 @@ export async function confirmAccountDeletion( `${apiUrl}/protected/delete-account/confirm`, "POST", confirmData, - "Failed to confirm account deletion" + "Failed to confirm account deletion", + expectedUserId ); } @@ -1413,14 +1425,15 @@ export async function listApiKeys(): Promise<{ keys: ApiKeyListResponse }> { * console.log("API key deleted successfully"); * ``` */ -export async function deleteApiKey(name: string): Promise { +export async function deleteApiKey(name: string, expectedUserId?: string): Promise { // URL-encode the name to handle special characters const encodedName = encodeURIComponent(name); return authenticatedApiCall( `${apiUrl}/protected/api-keys/${encodedName}`, "DELETE", undefined, - "Failed to delete API key" + "Failed to delete API key", + expectedUserId ); } @@ -1724,6 +1737,8 @@ export type ResponsesListParams = { export type ConversationItem = { id: string; + /** OpenSecret extension: response that created this item, when applicable. */ + response_id?: string; type: "message"; status: "completed" | "in_progress" | "incomplete"; role: "user" | "assistant" | "system"; @@ -2006,12 +2021,16 @@ export type ResponsesCancelResponse = { * } * ``` */ -export async function cancelResponse(responseId: string): Promise { +export async function cancelResponse( + responseId: string, + expectedUserId?: string +): Promise { return authenticatedApiCall( `${apiUrl}/v1/responses/${encodeURIComponent(responseId)}/cancel`, "POST", undefined, - "Failed to cancel response" + "Failed to cancel response", + expectedUserId ); } @@ -2172,13 +2191,15 @@ export async function updateConversation( * ``` */ export async function deleteConversation( - conversationId: string + conversationId: string, + expectedUserId?: string ): Promise { return authenticatedApiCall( `${apiUrl}/v1/conversations/${encodeURIComponent(conversationId)}`, "DELETE", undefined, - "Failed to delete conversation" + "Failed to delete conversation", + expectedUserId ); } @@ -2201,12 +2222,15 @@ export async function deleteConversation( * } * ``` */ -export async function deleteConversations(): Promise { +export async function deleteConversations( + expectedUserId?: string +): Promise { return authenticatedApiCall( `${apiUrl}/v1/conversations`, "DELETE", undefined, - "Failed to delete conversations" + "Failed to delete conversations", + expectedUserId ); } @@ -2238,13 +2262,15 @@ export async function deleteConversations(): Promise { return authenticatedApiCall( `${apiUrl}/v1/conversations/batch-delete`, "POST", { ids }, - "Failed to batch delete conversations" + "Failed to batch delete conversations", + expectedUserId ); } @@ -2498,13 +2524,15 @@ export async function updateConversationProject( } export async function deleteConversationProject( - projectId: string + projectId: string, + expectedUserId?: string ): Promise { return authenticatedApiCall( `${apiUrl}/v1/conversation-projects/${encodeURIComponent(projectId)}`, "DELETE", undefined, - "Failed to delete conversation project" + "Failed to delete conversation project", + expectedUserId ); } @@ -2570,12 +2598,16 @@ export async function createResponse( * } * ``` */ -export async function deleteResponse(responseId: string): Promise { +export async function deleteResponse( + responseId: string, + expectedUserId?: string +): Promise { return authenticatedApiCall( `${apiUrl}/v1/responses/${encodeURIComponent(responseId)}`, "DELETE", undefined, - "Failed to delete response" + "Failed to delete response", + expectedUserId ); } @@ -2786,12 +2818,16 @@ export async function updateInstruction( * } * ``` */ -export async function deleteInstruction(instructionId: string): Promise { +export async function deleteInstruction( + instructionId: string, + expectedUserId?: string +): Promise { return authenticatedApiCall( `${apiUrl}/v1/instructions/${encodeURIComponent(instructionId)}`, "DELETE", undefined, - "Failed to delete instruction" + "Failed to delete instruction", + expectedUserId ); } diff --git a/sdk/src/lib/credentialIdentity.ts b/sdk/src/lib/credentialIdentity.ts new file mode 100644 index 000000000..bb49f11e8 --- /dev/null +++ b/sdk/src/lib/credentialIdentity.ts @@ -0,0 +1,147 @@ +export const ACCOUNT_CREDENTIAL_MISMATCH_CODE = "chat_account_credential_mismatch"; + +export function accessTokenSubject(accessToken: string | null): string | null { + if (!accessToken) return null; + try { + const parts = accessToken.split("."); + if (parts.length !== 3 || !parts[1]) return null; + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const payload = JSON.parse(atob(padded)) as { sub?: unknown }; + return typeof payload.sub === "string" && payload.sub ? payload.sub : null; + } catch { + return null; + } +} + +export function accountCredentialMismatchError(): Error & { code: string } { + return Object.assign( + new Error("The authenticated account changed before this request could continue"), + { + name: "AccountCredentialMismatchError", + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE + } + ); +} + +export function isAccountCredentialMismatchError( + error: unknown +): error is Error & { code: string } { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === ACCOUNT_CREDENTIAL_MISMATCH_CODE + ); +} + +export type UserCredentialSnapshot = Readonly<{ + userId: string; + accessToken: string; + refreshToken: string | null; +}>; + +export function assertExpectedAccessTokenSubject( + expectedUserId: string, + storage: Storage = window.localStorage +): void { + if (accessTokenSubject(storage.getItem("access_token")) !== expectedUserId) { + throw accountCredentialMismatchError(); + } +} + +export function captureExpectedUserCredentials( + expectedUserId: string, + storage: Storage = window.localStorage +): UserCredentialSnapshot { + const accessToken = storage.getItem("access_token"); + const refreshToken = storage.getItem("refresh_token"); + + if ( + !accessToken || + accessTokenSubject(accessToken) !== expectedUserId || + storage.getItem("access_token") !== accessToken || + storage.getItem("refresh_token") !== refreshToken + ) { + throw accountCredentialMismatchError(); + } + + return { + userId: expectedUserId, + accessToken, + refreshToken + }; +} + +export function clearCapturedUserCredentials( + snapshot: UserCredentialSnapshot, + storage: Storage = window.localStorage +): void { + if ( + accessTokenSubject(storage.getItem("access_token")) !== snapshot.userId || + storage.getItem("access_token") !== snapshot.accessToken || + storage.getItem("refresh_token") !== snapshot.refreshToken + ) { + throw accountCredentialMismatchError(); + } + + // Recheck each exact value immediately before removal. This prevents a logout + // that crossed an asynchronous account transition from clearing the new + // account's credentials. + if (storage.getItem("access_token") !== snapshot.accessToken) { + throw accountCredentialMismatchError(); + } + storage.removeItem("access_token"); + + if (storage.getItem("refresh_token") !== snapshot.refreshToken) { + throw accountCredentialMismatchError(); + } + storage.removeItem("refresh_token"); +} + +export async function revokeAndClearUserCredentials({ + expectedUserId, + revokeRefreshToken, + storage = window.localStorage +}: { + expectedUserId?: string; + revokeRefreshToken: (refreshToken: string) => Promise; + storage?: Storage; +}): Promise { + const snapshot = expectedUserId + ? captureExpectedUserCredentials(expectedUserId, storage) + : undefined; + const refreshToken = snapshot ? snapshot.refreshToken : storage.getItem("refresh_token"); + + if (refreshToken) { + await revokeRefreshToken(refreshToken); + } + + if (snapshot) { + clearCapturedUserCredentials(snapshot, storage); + return; + } + + // An unauthenticated provider has no user identity to bind. Preserve its + // existing best-effort credential cleanup behavior. + storage.removeItem("access_token"); + storage.removeItem("refresh_token"); +} + +export function commitRefreshedUserTokensIfCurrent({ + initiatingRefreshToken, + accessToken, + refreshToken, + storage = window.localStorage +}: { + initiatingRefreshToken: string; + accessToken: string; + refreshToken: string; + storage?: Storage; +}): void { + if (storage.getItem("refresh_token") !== initiatingRefreshToken) { + throw accountCredentialMismatchError(); + } + storage.setItem("access_token", accessToken); + storage.setItem("refresh_token", refreshToken); +} diff --git a/sdk/src/lib/encryptedApi.ts b/sdk/src/lib/encryptedApi.ts index 76a088604..ad4890c53 100644 --- a/sdk/src/lib/encryptedApi.ts +++ b/sdk/src/lib/encryptedApi.ts @@ -5,6 +5,11 @@ import { getPlatformApiUrl, getPlatformPcrConfig, platformRefreshToken } from ". import { apiConfig } from "./apiConfig"; import { serializePcrConfig, snapshotPcrConfig, type PcrConfig } from "./pcr"; import { classifyRecovery } from "./recovery"; +import { + accessTokenSubject, + accountCredentialMismatchError, + isAccountCredentialMismatchError +} from "./credentialIdentity"; interface EncryptedResponse { encrypted: string; @@ -19,6 +24,7 @@ interface ApiResponse { interface RequestAuthentication { token?: string; refreshAccessToken?: () => Promise; + assertCurrentToken?: (attemptToken: string | undefined) => void; } interface ActiveAttestation { @@ -168,6 +174,7 @@ async function performEncryptedApiCall( ); let token = authentication.token; + authentication.assertCurrentToken?.(token); let attestation = await dependencies.getAttestation(false, explicitApiUrl, pcrConfig); let replayed = false; @@ -186,6 +193,9 @@ async function performEncryptedApiCall( while (true) { const session = await requireSession(false); + // Attestation and recovery can yield. Bind every transport attempt to + // the account that initiated this logical request. + authentication.assertCurrentToken?.(token); const encryptedData = plaintextBody ? dependencies.encryptMessage(session.sessionKey, plaintextBody) : undefined; @@ -200,6 +210,14 @@ async function performEncryptedApiCall( headers, body: encryptedData ? JSON.stringify({ encrypted: encryptedData }) : undefined }); + try { + // Do not publish one account's response into a replacement account's + // caller state, even when the transport itself succeeded. + authentication.assertCurrentToken?.(token); + } catch (error) { + await discardResponse(response); + throw error; + } const recovery = classifyRecovery(response.status, response.headers); if (!replayed && recovery === "renew_session") { @@ -218,7 +236,10 @@ async function performEncryptedApiCall( if (!replayed && recovery === "refresh_access_token" && authentication.refreshAccessToken) { replayed = true; await discardResponse(response); + // Never consume a replacement account's refresh credential. + authentication.assertCurrentToken?.(token); token = await authentication.refreshAccessToken(); + authentication.assertCurrentToken?.(token); // The encrypted refresh request can repair a stale session with its // own replay budget, so always reload the current session afterward. attestation = await dependencies.getAttestation(false, explicitApiUrl, pcrConfig); @@ -229,29 +250,41 @@ async function performEncryptedApiCall( if (!response.ok) { try { const errorBody = (await response.json()) as { message?: string }; + authentication.assertCurrentToken?.(token); result.error = errorBody.message || errorMessage || `HTTP error! Status: ${response.status}`; - } catch { + } catch (error) { + // Reading and parsing the body can yield. A credential replacement + // must win over the ordinary HTTP fallback rather than being hidden + // as a generic request error. + authentication.assertCurrentToken?.(token); + if (isAccountCredentialMismatchError(error)) throw error; result.error = errorMessage || `HTTP error! Status: ${response.status}`; } + authentication.assertCurrentToken?.(token); return result; } try { const encryptedResponse = (await response.json()) as EncryptedResponse; + authentication.assertCurrentToken?.(token); const decryptedResponse = dependencies.decryptMessage( session.sessionKey, encryptedResponse.encrypted ); result.data = JSON.parse(decryptedResponse) as U; } catch (error) { + authentication.assertCurrentToken?.(token); + if (isAccountCredentialMismatchError(error)) throw error; console.error("Error decrypting or parsing response:", error); result.status = 500; result.error = "Failed to decrypt or parse the response"; } + authentication.assertCurrentToken?.(token); return result; } } catch (error) { + if (isAccountCredentialMismatchError(error)) throw error; return { status: 500, error: error instanceof Error ? error.message : "Unknown error occurred" @@ -269,9 +302,17 @@ export async function authenticatedApiCall( url: string, method: string, data: T, - errorMessage?: string + errorMessage?: string, + expectedUserId?: string ): Promise { - return authenticatedApiCallWithDependencies(url, method, data, errorMessage, defaultDependencies); + return authenticatedApiCallWithDependencies( + url, + method, + data, + errorMessage, + defaultDependencies, + expectedUserId + ); } /** @internal Exported for deterministic transport tests, not from the package entry point. */ @@ -280,11 +321,26 @@ export async function authenticatedApiCallWithDependencies( method: string, data: T, errorMessage: string | undefined, - dependencies: EncryptedApiDependencies + dependencies: EncryptedApiDependencies, + expectedUserId?: string ): Promise { try { const accessToken = dependencies.getAccessToken(); if (!accessToken) throw new Error("No access token available"); + const expectedSubject = expectedUserId ?? accessTokenSubject(accessToken); + if (!expectedSubject || accessTokenSubject(accessToken) !== expectedSubject) { + throw accountCredentialMismatchError(); + } + + const assertCurrentToken = (attemptToken: string | undefined) => { + if ( + accessTokenSubject(attemptToken ?? null) !== expectedSubject || + accessTokenSubject(dependencies.getAccessToken()) !== expectedSubject + ) { + throw accountCredentialMismatchError(); + } + }; + assertCurrentToken(accessToken); const response = await performEncryptedApiCall( url, @@ -292,16 +348,22 @@ export async function authenticatedApiCallWithDependencies( data, { token: accessToken, + assertCurrentToken, refreshAccessToken: async () => { + assertCurrentToken(accessToken); await dependencies.refreshAccessToken(url); const refreshedToken = dependencies.getAccessToken(); if (!refreshedToken) throw new Error("No access token available"); + assertCurrentToken(refreshedToken); return refreshedToken; } }, errorMessage, dependencies ); + // The inner request resolves through another promise boundary. Re-check + // before exposing either its data or its error to the authenticated caller. + assertCurrentToken(accessToken); return unwrapApiResponse(response, "No data received from the server"); } catch (error) { console.error(error); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index bd18e8879..ae6f5c9a2 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -114,7 +114,12 @@ export { } from "./api"; // Export AI customization options -export { createCustomFetch, type CustomFetchOptions } from "./ai"; +export { + createCustomFetch, + REQUEST_NOT_DISPATCHED_CODE, + type CustomFetchOptions, + type RequestNotDispatchedMarker +} from "./ai"; // Re-export Model type from OpenAI for convenience export type { Model } from "openai/resources/models.js"; diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index e5a23ca88..677a63b84 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -13,6 +13,7 @@ import { import type { AttestationDocument } from "./attestation"; import type { LoginResponse, ThirdPartyTokenResponse, DocumentResponse } from "./api"; import { PcrConfig } from "./pcr"; +import { revokeAndClearUserCredentials } from "./credentialIdentity"; const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "production" }; @@ -1034,6 +1035,7 @@ export function OpenSecretProvider({ loading: true, user: undefined }); + const authenticatedUserId = auth.user?.user.id; const [apiKey, setApiKeyState] = useState(); const [aiCustomFetch, setAiCustomFetch] = useState(); @@ -1079,11 +1081,13 @@ export function OpenSecretProvider({ useEffect(() => { if (apiUrl) { // Pass API key if available, otherwise falls back to JWT - setAiCustomFetch(() => createCustomFetch({ apiKey, apiUrl, pcrConfig })); + setAiCustomFetch(() => + createCustomFetch({ apiKey, apiUrl, pcrConfig, expectedUserId: authenticatedUserId }) + ); } else { setAiCustomFetch(undefined); } - }, [apiUrl, apiKey, pcrConfig]); + }, [apiUrl, apiKey, authenticatedUserId, pcrConfig]); async function fetchUser() { const access_token = window.localStorage.getItem("access_token"); @@ -1185,16 +1189,16 @@ export function OpenSecretProvider({ } async function signOut() { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (refresh_token) { - try { - await api.fetchLogout(refresh_token); - } catch (error) { - console.error("Error during logout:", error); + await revokeAndClearUserCredentials({ + expectedUserId: authenticatedUserId, + revokeRefreshToken: async (refreshToken) => { + try { + await api.fetchLogout(refreshToken); + } catch (error) { + console.error("Error during logout:", error); + } } - } - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); + }); clearAttestationSessions(); // Clear any in-memory API key so no post-logout calls can use it setApiKey(undefined); @@ -1332,8 +1336,8 @@ export function OpenSecretProvider({ get: api.fetchGet, put: api.fetchPut, list: api.fetchList, - del: api.fetchDelete, - delAll: api.fetchDeleteAllKV, + del: (key) => api.fetchDelete(key, authenticatedUserId), + delAll: () => api.fetchDeleteAllKV(authenticatedUserId), refetchUser: fetchUser, verifyEmail: api.verifyEmail, requestNewVerificationCode: api.requestNewVerificationCode, @@ -1348,8 +1352,10 @@ export function OpenSecretProvider({ plaintextSecret: string, newPassword: string ) => api.confirmPasswordReset(email, alphanumericCode, plaintextSecret, newPassword, clientId), - requestAccountDeletion: api.requestAccountDeletion, - confirmAccountDeletion: api.confirmAccountDeletion, + requestAccountDeletion: (hashedSecret) => + api.requestAccountDeletion(hashedSecret, authenticatedUserId), + confirmAccountDeletion: (confirmationCode, plaintextSecret) => + api.confirmAccountDeletion(confirmationCode, plaintextSecret, authenticatedUserId), initiateGitHubAuth, handleGitHubCallback, initiateGoogleAuth, @@ -1381,35 +1387,37 @@ export function OpenSecretProvider({ uploadDocumentWithPolling: api.uploadDocumentWithPolling, createApiKey: api.createApiKey, listApiKeys: api.listApiKeys, - deleteApiKey: api.deleteApiKey, + deleteApiKey: (name) => api.deleteApiKey(name, authenticatedUserId), transcribeAudio: api.transcribeAudio, webSearch: api.webSearch, webExtract: api.webExtract, fetchResponsesList: api.fetchResponsesList, fetchResponse: api.fetchResponse, - cancelResponse: api.cancelResponse, - deleteResponse: api.deleteResponse, + cancelResponse: (responseId) => api.cancelResponse(responseId, authenticatedUserId), + deleteResponse: (responseId) => api.deleteResponse(responseId, authenticatedUserId), createResponse: api.createResponse, createConversation: api.createConversation, getConversation: api.getConversation, updateConversation: api.updateConversation, - deleteConversation: api.deleteConversation, + deleteConversation: (conversationId) => + api.deleteConversation(conversationId, authenticatedUserId), listConversationItems: api.listConversationItems, getConversationItem: api.getConversationItem, listConversations: api.listConversations, - deleteConversations: api.deleteConversations, - batchDeleteConversations: api.batchDeleteConversations, + deleteConversations: () => api.deleteConversations(authenticatedUserId), + batchDeleteConversations: (ids) => api.batchDeleteConversations(ids, authenticatedUserId), batchUpdateConversationProject: api.batchUpdateConversationProject, createConversationProject: api.createConversationProject, listConversationProjects: api.listConversationProjects, getConversationProject: api.getConversationProject, updateConversationProject: api.updateConversationProject, - deleteConversationProject: api.deleteConversationProject, + deleteConversationProject: (projectId) => + api.deleteConversationProject(projectId, authenticatedUserId), createInstruction: api.createInstruction, listInstructions: api.listInstructions, getInstruction: api.getInstruction, updateInstruction: api.updateInstruction, - deleteInstruction: api.deleteInstruction, + deleteInstruction: (instructionId) => api.deleteInstruction(instructionId, authenticatedUserId), setDefaultInstruction: api.setDefaultInstruction }; diff --git a/sdk/src/lib/test/credentialIdentity.test.ts b/sdk/src/lib/test/credentialIdentity.test.ts new file mode 100644 index 000000000..9f2734dfe --- /dev/null +++ b/sdk/src/lib/test/credentialIdentity.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + ACCOUNT_CREDENTIAL_MISMATCH_CODE, + accessTokenSubject, + assertExpectedAccessTokenSubject, + captureExpectedUserCredentials, + clearCapturedUserCredentials, + commitRefreshedUserTokensIfCurrent, + isAccountCredentialMismatchError, + revokeAndClearUserCredentials +} from "../credentialIdentity"; + +function tokenForSubject(subject: string): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode({ sub: subject })}.signature`; +} + +describe("credential identity", () => { + beforeEach(() => window.localStorage.clear()); + + test("extracts the stable subject from refreshed JWTs", () => { + expect(accessTokenSubject(tokenForSubject("user-a"))).toBe("user-a"); + expect(accessTokenSubject("invalid")).toBeNull(); + }); + + test("asserts that the current access token belongs to the expected user", () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + + expect(() => assertExpectedAccessTokenSubject("user-a")).not.toThrow(); + expect(() => assertExpectedAccessTokenSubject("user-b")).toThrow(); + }); + + test("recognizes credential mismatch errors without matching arbitrary errors", () => { + expect(isAccountCredentialMismatchError({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE })).toBe(true); + expect(isAccountCredentialMismatchError(new Error("network"))).toBe(false); + }); + + test("captures and clears an unchanged same-account credential pair", () => { + const accessToken = tokenForSubject("user-a"); + window.localStorage.setItem("access_token", accessToken); + window.localStorage.setItem("refresh_token", "refresh-a"); + + const snapshot = captureExpectedUserCredentials("user-a"); + expect(snapshot).toEqual({ + userId: "user-a", + accessToken, + refreshToken: "refresh-a" + }); + + clearCapturedUserCredentials(snapshot); + + expect(window.localStorage.getItem("access_token")).toBeNull(); + expect(window.localStorage.getItem("refresh_token")).toBeNull(); + }); + + test("rejects a stale provider before it can capture another account's refresh token", () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + window.localStorage.setItem("refresh_token", "refresh-b"); + + expect(() => captureExpectedUserCredentials("user-a")).toThrow(); + expect(window.localStorage.getItem("access_token")).toBe(tokenForSubject("user-b")); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-b"); + }); + + test("does not clear another account's credentials after an in-flight logout", () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + window.localStorage.setItem("refresh_token", "refresh-a"); + const snapshot = captureExpectedUserCredentials("user-a"); + + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + window.localStorage.setItem("refresh_token", "refresh-b"); + + expect(() => clearCapturedUserCredentials(snapshot)).toThrow(); + expect(window.localStorage.getItem("access_token")).toBe(tokenForSubject("user-b")); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-b"); + }); + + test("does not clear replacement credentials for the same account", () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + window.localStorage.setItem("refresh_token", "refresh-a"); + const snapshot = captureExpectedUserCredentials("user-a"); + + const refreshedAccessToken = `${tokenForSubject("user-a")}-refreshed`; + window.localStorage.setItem("access_token", refreshedAccessToken); + window.localStorage.setItem("refresh_token", "refresh-a-new"); + + expect(() => clearCapturedUserCredentials(snapshot)).toThrow(); + expect(window.localStorage.getItem("access_token")).toBe(refreshedAccessToken); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-a-new"); + }); + + test("does not revoke credentials when the provider is already stale", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + window.localStorage.setItem("refresh_token", "refresh-b"); + const revokedTokens: string[] = []; + + await expect( + revokeAndClearUserCredentials({ + expectedUserId: "user-a", + revokeRefreshToken: async (refreshToken) => { + revokedTokens.push(refreshToken); + } + }) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + + expect(revokedTokens).toEqual([]); + expect(window.localStorage.getItem("access_token")).toBe(tokenForSubject("user-b")); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-b"); + }); + + test("revokes only the captured account and preserves a replacement during logout", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + window.localStorage.setItem("refresh_token", "refresh-a"); + let releaseLogout!: () => void; + const revokedTokens: string[] = []; + + const logout = revokeAndClearUserCredentials({ + expectedUserId: "user-a", + revokeRefreshToken: async (refreshToken) => { + revokedTokens.push(refreshToken); + await new Promise((resolve) => { + releaseLogout = resolve; + }); + } + }); + + expect(revokedTokens).toEqual(["refresh-a"]); + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + window.localStorage.setItem("refresh_token", "refresh-b"); + releaseLogout(); + + await expect(logout).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(window.localStorage.getItem("access_token")).toBe(tokenForSubject("user-b")); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-b"); + }); + + test("preserves same-account logout and unauthenticated cleanup behavior", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + window.localStorage.setItem("refresh_token", "refresh-a"); + const revokedTokens: string[] = []; + + await revokeAndClearUserCredentials({ + expectedUserId: "user-a", + revokeRefreshToken: async (refreshToken) => { + revokedTokens.push(refreshToken); + } + }); + + expect(revokedTokens).toEqual(["refresh-a"]); + expect(window.localStorage.getItem("access_token")).toBeNull(); + expect(window.localStorage.getItem("refresh_token")).toBeNull(); + + window.localStorage.setItem("access_token", "unverified-access"); + window.localStorage.setItem("refresh_token", "unverified-refresh"); + await revokeAndClearUserCredentials({ + revokeRefreshToken: async (refreshToken) => { + revokedTokens.push(refreshToken); + } + }); + + expect(revokedTokens).toEqual(["refresh-a", "unverified-refresh"]); + expect(window.localStorage.getItem("access_token")).toBeNull(); + expect(window.localStorage.getItem("refresh_token")).toBeNull(); + }); + + test("commits a refresh only while its initiating credential still owns storage", () => { + window.localStorage.setItem("refresh_token", "refresh-a"); + + commitRefreshedUserTokensIfCurrent({ + initiatingRefreshToken: "refresh-a", + accessToken: "access-a-new", + refreshToken: "refresh-a-new" + }); + + expect(window.localStorage.getItem("access_token")).toBe("access-a-new"); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-a-new"); + }); + + test("does not overwrite credentials replaced while refresh was in flight", () => { + window.localStorage.setItem("access_token", "access-b"); + window.localStorage.setItem("refresh_token", "refresh-b"); + + expect(() => + commitRefreshedUserTokensIfCurrent({ + initiatingRefreshToken: "refresh-a", + accessToken: "late-access-a", + refreshToken: "late-refresh-a" + }) + ).toThrow(); + + try { + commitRefreshedUserTokensIfCurrent({ + initiatingRefreshToken: "refresh-a", + accessToken: "late-access-a", + refreshToken: "late-refresh-a" + }); + } catch (error) { + expect((error as { code?: string }).code).toBe(ACCOUNT_CREDENTIAL_MISMATCH_CODE); + } + expect(window.localStorage.getItem("access_token")).toBe("access-b"); + expect(window.localStorage.getItem("refresh_token")).toBe("refresh-b"); + }); +}); diff --git a/sdk/src/lib/test/customFetch.test.ts b/sdk/src/lib/test/customFetch.test.ts index 5109364b6..4a365e167 100644 --- a/sdk/src/lib/test/customFetch.test.ts +++ b/sdk/src/lib/test/customFetch.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { createCustomFetchWithDependencies, type CustomFetchDependencies } from "../ai"; +import { + ACCOUNT_CREDENTIAL_MISMATCH_CODE, + createCustomFetchWithDependencies, + REQUEST_NOT_DISPATCHED_CODE, + type CustomFetchDependencies +} from "../ai"; import { getApiPcrConfig, getApiUrl, setApiUrl } from "../api"; import type { Attestation } from "../getAttestation"; import type { PcrConfig } from "../pcr"; @@ -45,6 +50,12 @@ function contractError(status: number, body: string, code?: string): Response { return new Response(body, { status, headers }); } +function tokenForSubject(subject: string, generation = 1): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode({ sub: subject, generation })}.sig`; +} + function dependencies(overrides: Partial): CustomFetchDependencies { return { decryptMessage: decryptForTest, @@ -102,6 +113,383 @@ describe("createCustomFetch stale-session recovery", () => { window.sessionStorage.clear(); }); + test("rejects a mismatched account before attestation or transport", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + let attestations = 0; + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + getAttestation: async () => { + attestations += 1; + return staleAttestation; + }, + fetch: async () => { + requests += 1; + return Response.json({}); + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE, + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + expect(attestations).toBe(0); + expect(requests).toBe(0); + }); + + test("rechecks account ownership after attestation yields", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + getAttestation: async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return staleAttestation; + }, + fetch: async () => { + requests += 1; + return Response.json({}); + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE + }); + expect(requests).toBe(0); + }); + + test("rechecks account ownership after request encryption before transport", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + encryptMessage: (sessionKey, plaintext) => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return encryptForTest(sessionKey, plaintext); + }, + fetch: async () => { + requests += 1; + return Response.json({}); + } + }) + ); + + await expect( + customFetch("https://example.test/v1/responses", { + method: "POST", + body: JSON.stringify({ input: "private" }) + }) + ).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE, + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + expect(requests).toBe(0); + }); + + test("tags an initial attestation failure as definitely not dispatched", async () => { + const attestationError = new Error("attestation unavailable"); + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { apiKey: "test-api-key" }, + dependencies({ + getAttestation: async () => { + throw attestationError; + }, + fetch: async () => { + requests += 1; + return Response.json({}); + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(attestationError); + expect(attestationError).toMatchObject({ + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + expect(requests).toBe(0); + }); + + test("does not tag a fetch rejection after dispatch was attempted", async () => { + const transportError = Object.assign(new Error("network failed"), { code: "network_error" }); + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { apiKey: "test-api-key" }, + dependencies({ + fetch: async () => { + requests += 1; + throw transportError; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(transportError); + expect((transportError as { code?: string }).code).not.toBe(REQUEST_NOT_DISPATCHED_CODE); + expect(transportError).not.toHaveProperty("requestDispatchCode"); + expect(transportError).not.toHaveProperty("definitelyNotDispatched"); + expect(requests).toBe(1); + }); + + test("does not refresh with credentials replaced by another account", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let requests = 0; + let refreshes = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + fetch: async () => { + requests += 1; + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return contractError(401, "expired", "access_token_expired"); + }, + refreshToken: async () => { + refreshes += 1; + throw new Error("must not refresh replacement credentials"); + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE + }); + expect(requests).toBe(1); + expect(refreshes).toBe(0); + }); + + test("allows a JWT refresh that retains the expected account subject", async () => { + const initialToken = tokenForSubject("user-a", 1); + const refreshedToken = tokenForSubject("user-a", 2); + window.localStorage.setItem("access_token", initialToken); + const authorizations: Array = []; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + fetch: async (_input, init) => { + authorizations.push(recordRequest(init).authorization); + return authorizations.length === 1 + ? contractError(401, "expired", "access_token_expired") + : Response.json({ encrypted: '1:{"ok":true}' }); + }, + refreshToken: async () => { + window.localStorage.setItem("access_token", refreshedToken); + return { access_token: refreshedToken, refresh_token: "same-account-refresh" }; + } + }) + ); + + expect(await (await customFetch("https://example.test/v1/responses")).json()).toEqual({ + ok: true + }); + expect(authorizations).toEqual([`Bearer ${initialToken}`, `Bearer ${refreshedToken}`]); + }); + + test("marks refresh setup failures after a definitive rejection as not accepted", async () => { + const initialToken = tokenForSubject("user-a", 1); + window.localStorage.setItem("access_token", initialToken); + const refreshError = new Error("refresh unavailable"); + let requests = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + fetch: async () => { + requests += 1; + return contractError(401, "expired", "access_token_expired"); + }, + refreshToken: async () => { + throw refreshError; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(refreshError); + expect(refreshError).toMatchObject({ + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + expect(requests).toBe(1); + }); + + test("marks a truncated 4xx error body as definitively rejected", async () => { + const bodyError = new Error("error body truncated"); + const customFetch = createCustomFetchWithDependencies( + { apiKey: "test-api-key" }, + dependencies({ + fetch: async () => { + const response = new Response(null, { status: 422 }); + Object.defineProperty(response, "text", { + value: async () => { + throw bodyError; + } + }); + return response; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(bodyError); + expect(bodyError).toMatchObject({ + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + }); + + test("keeps a truncated generic versioned 5xx body ambiguous", async () => { + const bodyError = new Error("error body truncated"); + const customFetch = createCustomFetchWithDependencies( + { apiKey: "test-api-key" }, + dependencies({ + fetch: async () => { + const response = new Response(null, { + status: 500, + headers: { "x-opensecret-error-contract": "1" } + }); + Object.defineProperty(response, "text", { + value: async () => { + throw bodyError; + } + }); + return response; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(bodyError); + expect(bodyError).not.toHaveProperty("requestDispatchCode"); + expect(bodyError).not.toHaveProperty("definitelyNotDispatched"); + }); + + test("marks a truncated coded pre-acceptance 5xx body as definitively rejected", async () => { + const bodyError = new Error("error body truncated"); + const customFetch = createCustomFetchWithDependencies( + { apiKey: "test-api-key" }, + dependencies({ + fetch: async () => { + const response = contractError( + 503, + "upstream unavailable", + "image_description_unavailable" + ); + Object.defineProperty(response, "text", { + value: async () => { + throw bodyError; + } + }); + return response; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toBe(bodyError); + expect(bodyError).toMatchObject({ + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); + }); + + test("rejects an encrypted JSON result if the account changes while reading its body", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + const options = { expectedUserId: "user-a" }; + let decryptions = 0; + const customFetch = createCustomFetchWithDependencies( + options, + dependencies({ + decryptMessage: (sessionKey, ciphertext) => { + decryptions += 1; + return decryptForTest(sessionKey, ciphertext); + }, + fetch: async () => { + const response = Response.json({ encrypted: '1:{"private":"user-a"}' }); + Object.defineProperty(response, "text", { + value: async () => { + await Promise.resolve(); + options.expectedUserId = "user-b"; + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return '{"encrypted":"1:{\\"private\\":\\"user-a\\"}"}'; + } + }); + return response; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE + }); + expect(decryptions).toBe(0); + }); + + test("rejects an error body if the account changes while reading it", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + fetch: async () => { + const response = contractError(403, "private failure"); + Object.defineProperty(response, "text", { + value: async () => { + await Promise.resolve(); + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return "private failure"; + } + }); + return response; + } + }) + ); + + await expect(customFetch("https://example.test/v1/responses")).rejects.toMatchObject({ + code: ACCOUNT_CREDENTIAL_MISMATCH_CODE + }); + }); + + test("errors an SSE body before enqueueing after the account changes", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let releaseBody!: () => void; + const bodyReleased = new Promise((resolve) => { + releaseBody = resolve; + }); + let decryptions = 0; + const customFetch = createCustomFetchWithDependencies( + { expectedUserId: "user-a" }, + dependencies({ + decryptMessage: (sessionKey, ciphertext) => { + decryptions += 1; + return decryptForTest(sessionKey, ciphertext); + }, + fetch: async () => + new Response( + new ReadableStream({ + async start(controller) { + await bodyReleased; + controller.enqueue( + new TextEncoder().encode( + 'event: response.output_text.delta\ndata: 1:{"delta":"private"}\n\n' + ) + ); + controller.close(); + } + }), + { headers: { "content-type": "text/event-stream" } } + ) + }) + ); + + const response = await customFetch("https://example.test/v1/responses"); + const result = response.text(); + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + releaseBody(); + + await expect(result).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(decryptions).toBe(0); + }); + test("renews once and rebuilds an API-key request with the fresh session", async () => { let currentAttestation = staleAttestation; let forcedAttestations = 0; @@ -805,7 +1193,11 @@ describe("createCustomFetch stale-session recovery", () => { await expect( customFetch("https://example.test/v1/responses", { signal: controller.signal }) - ).rejects.toMatchObject({ name: "AbortError" }); + ).rejects.toMatchObject({ + name: "AbortError", + requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE, + definitelyNotDispatched: true + }); expect(attestations).toBe(0); expect(requests).toBe(0); }); diff --git a/sdk/src/lib/test/encryptedApi.test.ts b/sdk/src/lib/test/encryptedApi.test.ts index 281e8f44e..918e7feeb 100644 --- a/sdk/src/lib/test/encryptedApi.test.ts +++ b/sdk/src/lib/test/encryptedApi.test.ts @@ -8,6 +8,7 @@ import { import type { Attestation } from "../getAttestation"; import { snapshotPcrConfig } from "../pcr"; import { ERROR_CODE_HEADER, ERROR_CONTRACT_HEADER } from "../recovery"; +import { ACCOUNT_CREDENTIAL_MISMATCH_CODE } from "../credentialIdentity"; const staleKey = new Uint8Array(32).fill(1); const freshKey = new Uint8Array(32).fill(2); @@ -37,6 +38,12 @@ function encryptedSuccess(sessionKey: Uint8Array, value: unknown): Response { ); } +function tokenForSubject(subject: string, generation = 1): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode({ sub: subject, generation })}.sig`; +} + function dependencies(overrides: Partial = {}): EncryptedApiDependencies { return { decryptMessage: decryptForTest, @@ -294,14 +301,224 @@ describe("encrypted API recovery", () => { expect(sessionIds).not.toContain("late-extra-session"); }); + test("an authenticated request fails before transport if attestation yields to another account", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let sends = 0; + let refreshes = 0; + const deps = dependencies({ + getAttestation: async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return staleAttestation; + }, + refreshAccessToken: async () => { + refreshes += 1; + }, + fetch: async () => { + sends += 1; + return encryptedSuccess(staleKey, { ok: true }); + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/destructive-action", + "DELETE", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(sends).toBe(0); + expect(refreshes).toBe(0); + }); + + test("a provider-bound request rejects a replacement token before attestation", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + let attestations = 0; + let sends = 0; + const deps = dependencies({ + getAttestation: async () => { + attestations += 1; + return staleAttestation; + }, + fetch: async () => { + sends += 1; + return encryptedSuccess(staleKey, { ok: true }); + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/destructive-action", + "DELETE", + undefined, + undefined, + deps, + "user-a" + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(attestations).toBe(0); + expect(sends).toBe(0); + }); + + test("does not publish a successful response after the account changes in flight", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let sends = 0; + const deps = dependencies({ + fetch: async () => { + sends += 1; + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return encryptedSuccess(staleKey, { private: "user-a" }); + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/user", + "GET", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(sends).toBe(1); + }); + + test("does not publish an encrypted result if the account changes while reading its body", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + let decryptions = 0; + const deps = dependencies({ + decryptMessage: (sessionKey, ciphertext) => { + decryptions += 1; + return decryptForTest(sessionKey, ciphertext); + }, + fetch: async () => { + const response = encryptedSuccess(staleKey, { private: "user-a" }); + Object.defineProperty(response, "json", { + value: async () => { + await Promise.resolve(); + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return { encrypted: encryptForTest(staleKey, '{"private":"user-a"}') }; + } + }); + return response; + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/user", + "GET", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(decryptions).toBe(0); + }); + + test("does not downgrade an account change during an error-body read to an HTTP error", async () => { + window.localStorage.setItem("access_token", tokenForSubject("user-a")); + const deps = dependencies({ + fetch: async () => { + const response = contractError(403, "Forbidden"); + Object.defineProperty(response, "json", { + value: async () => { + await Promise.resolve(); + window.localStorage.setItem("access_token", tokenForSubject("user-b")); + return { message: "Forbidden" }; + } + }); + return response; + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/user", + "DELETE", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + }); + + test("a 401 response cannot refresh or replay under a replacement account", async () => { + const accessTokenA = tokenForSubject("user-a"); + const accessTokenB = tokenForSubject("user-b"); + window.localStorage.setItem("access_token", accessTokenA); + let sends = 0; + let refreshes = 0; + const authorizations: Array = []; + const deps = dependencies({ + refreshAccessToken: async () => { + refreshes += 1; + }, + fetch: async (_input, init) => { + sends += 1; + authorizations.push(new Headers(init?.headers).get("Authorization")); + window.localStorage.setItem("access_token", accessTokenB); + return contractError(401, "Invalid JWT", "access_token_expired"); + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/destructive-action", + "DELETE", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(sends).toBe(1); + expect(refreshes).toBe(0); + expect(authorizations).toEqual([`Bearer ${accessTokenA}`]); + }); + + test("an account replacement during refresh cannot replay the retained request", async () => { + const accessTokenA = tokenForSubject("user-a"); + const accessTokenB = tokenForSubject("user-b"); + window.localStorage.setItem("access_token", accessTokenA); + let sends = 0; + let refreshes = 0; + const deps = dependencies({ + refreshAccessToken: async () => { + refreshes += 1; + window.localStorage.setItem("access_token", accessTokenB); + }, + fetch: async () => { + sends += 1; + return sends === 1 + ? contractError(401, "Invalid JWT", "access_token_expired") + : encryptedSuccess(staleKey, { ok: true }); + } + }); + + await expect( + authenticatedApiCallWithDependencies( + "https://api.example.test/protected/destructive-action", + "DELETE", + undefined, + undefined, + deps + ) + ).rejects.toMatchObject({ code: ACCOUNT_CREDENTIAL_MISMATCH_CODE }); + expect(sends).toBe(1); + expect(refreshes).toBe(1); + }); + test("v1 access-token expiry refreshes once and replays with the new token", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); + const expiredAccessToken = tokenForSubject("user-a", 1); + const freshAccessToken = tokenForSubject("user-a", 2); + window.localStorage.setItem("access_token", expiredAccessToken); let tokenRefreshes = 0; const authorizations: Array = []; const deps = dependencies({ refreshAccessToken: async () => { tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); + window.localStorage.setItem("access_token", freshAccessToken); }, fetch: async (_input, init) => { authorizations.push(new Headers(init?.headers).get("Authorization")); @@ -321,7 +538,7 @@ describe("encrypted API recovery", () => { ) ).toEqual({ ok: true }); expect(tokenRefreshes).toBe(1); - expect(authorizations).toEqual(["Bearer expired-access-token", "Bearer fresh-access-token"]); + expect(authorizations).toEqual([`Bearer ${expiredAccessToken}`, `Bearer ${freshAccessToken}`]); }); for (const ordinary of [ @@ -329,7 +546,7 @@ describe("encrypted API recovery", () => { { status: 401, code: "invalid_jwt", message: "Invalid JWT" } ]) { test(`v1 ordinary ${ordinary.status} fails closed`, async () => { - window.localStorage.setItem("access_token", "access-token"); + window.localStorage.setItem("access_token", tokenForSubject("user-a")); let sends = 0; let forcedAttestations = 0; let tokenRefreshes = 0; @@ -363,7 +580,9 @@ describe("encrypted API recovery", () => { } test("headerless 400 and 401 retain legacy recovery", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); + const expiredAccessToken = tokenForSubject("user-a", 1); + const freshAccessToken = tokenForSubject("user-a", 2); + window.localStorage.setItem("access_token", expiredAccessToken); let currentAttestation = staleAttestation; let sessionSends = 0; let authSends = 0; @@ -379,7 +598,7 @@ describe("encrypted API recovery", () => { }, refreshAccessToken: async () => { tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); + window.localStorage.setItem("access_token", freshAccessToken); }, fetch: async (input) => { if (String(input).endsWith("/legacy-session")) { @@ -421,7 +640,7 @@ describe("encrypted API recovery", () => { }); test("one target replay budget stops alternating recovery reasons", async () => { - window.localStorage.setItem("access_token", "access-token"); + window.localStorage.setItem("access_token", tokenForSubject("user-a")); let currentAttestation = staleAttestation; let sends = 0; let forcedAttestations = 0; @@ -460,16 +679,16 @@ describe("encrypted API recovery", () => { }); test("expired target JWT can refresh through one stale-session repair", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); + const expiredAccessToken = tokenForSubject("user-a", 1); + const freshAccessToken = tokenForSubject("user-a", 2); + window.localStorage.setItem("access_token", expiredAccessToken); let currentAttestation = staleAttestation; let forcedAttestations = 0; let targetSends = 0; let refreshSends = 0; const targetRequests: ReturnType[] = []; const refreshRequests: ReturnType[] = []; - let deps!: EncryptedApiDependencies; - - deps = dependencies({ + const deps = dependencies({ getAttestation: async (forceRefresh) => { if (forceRefresh) { forcedAttestations += 1; @@ -501,7 +720,7 @@ describe("encrypted API recovery", () => { return refreshSends === 1 ? contractError(400, "Bad Request", "session_not_found") : encryptedSuccess(freshKey, { - access_token: "fresh-access-token", + access_token: freshAccessToken, refresh_token: "fresh-refresh-token" }); } @@ -532,13 +751,13 @@ describe("encrypted API recovery", () => { ]); expect(targetRequests).toEqual([ { - authorization: "Bearer expired-access-token", + authorization: `Bearer ${expiredAccessToken}`, method: "POST", plaintext: '{"prompt":"same prompt"}', sessionId: "stale-session" }, { - authorization: "Bearer fresh-access-token", + authorization: `Bearer ${freshAccessToken}`, method: "POST", plaintext: '{"prompt":"same prompt"}', sessionId: "fresh-session" diff --git a/sdk/src/lib/test/integration/platformPushSettings.test.ts b/sdk/src/lib/test/integration/platformPushSettings.test.ts index 7438ab808..2a6b5dec0 100644 --- a/sdk/src/lib/test/integration/platformPushSettings.test.ts +++ b/sdk/src/lib/test/integration/platformPushSettings.test.ts @@ -10,10 +10,11 @@ import { updatePushSettings, type PushSettings } from "../../platformApi"; +import { testAccessTokenForSubject } from "../utils"; const sessionKey = new Uint8Array(32).fill(7); const sessionId = "push-settings-session-id"; -const accessToken = "push-settings-access-token"; +const accessToken = testAccessTokenForSubject("push-settings-user"); const platformApiUrl = "https://platform.example.com"; const verifiedPcr0 = "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; diff --git a/sdk/src/lib/test/integration/web.test.ts b/sdk/src/lib/test/integration/web.test.ts index aa00c7233..2db28338e 100644 --- a/sdk/src/lib/test/integration/web.test.ts +++ b/sdk/src/lib/test/integration/web.test.ts @@ -13,9 +13,10 @@ import { type WebSearchRequest, type WebSearchResponse } from "../../api"; +import { testAccessTokenForSubject } from "../utils"; const apiUrl = "https://api.example.com"; -const accessToken = "web-access-token"; +const accessToken = testAccessTokenForSubject("web-user"); const sessionId = "web-session-id"; const sessionKey = new Uint8Array(32).fill(19); const verifiedPcr0 = diff --git a/sdk/src/lib/test/models.test.ts b/sdk/src/lib/test/models.test.ts index 63e7c9e15..e8957831b 100644 --- a/sdk/src/lib/test/models.test.ts +++ b/sdk/src/lib/test/models.test.ts @@ -3,6 +3,7 @@ import { encryptMessage } from "../encryption"; import { cacheAttestationSessionForTesting } from "../getAttestation"; import type { PcrConfig } from "../pcr"; import { fetchModelCatalog, fetchModels, getApiPcrConfig, getApiUrl, setApiUrl } from "../api"; +import { testAccessTokenForSubject } from "./utils"; const apiUrl = "https://models.example.com"; const sessionId = "models-session-id"; @@ -71,11 +72,12 @@ test("fetchModels uses the encrypted session before sign-in", async () => { }); test("fetchModels preserves stored JWT authentication", async () => { - window.localStorage.setItem("access_token", "models-access-token"); + const accessToken = testAccessTokenForSubject("models-user"); + window.localStorage.setItem("access_token", accessToken); globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer models-access-token"); + expect(headers.get("Authorization")).toBe(`Bearer ${accessToken}`); expect(headers.get("x-session-id")).toBe(sessionId); return encryptedModelsResponse(); }) as typeof fetch; @@ -85,12 +87,13 @@ test("fetchModels preserves stored JWT authentication", async () => { test("fetchModels never downgrades a rejected stored JWT to anonymous access", async () => { let requestCount = 0; - window.localStorage.setItem("access_token", "rejected-access-token"); + const accessToken = testAccessTokenForSubject("models-user"); + window.localStorage.setItem("access_token", accessToken); globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { requestCount += 1; const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer rejected-access-token"); + expect(headers.get("Authorization")).toBe(`Bearer ${accessToken}`); return Response.json({ message: "Invalid JWT" }, { status: 401 }); }) as typeof fetch; diff --git a/sdk/src/lib/test/utils.ts b/sdk/src/lib/test/utils.ts index a590672c8..f04ca94ba 100644 --- a/sdk/src/lib/test/utils.ts +++ b/sdk/src/lib/test/utils.ts @@ -11,3 +11,9 @@ export function bytesToHex(bytes: Uint8Array): string { .map((b) => b.toString(16).padStart(2, "0")) .join(""); } + +export function testAccessTokenForSubject(subject: string): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode({ sub: subject })}.sig`; +} From 93e04d69093d310bf1e0b3f2f3748c03e11d8685 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:13:15 +0000 Subject: [PATCH 3/5] feat(chat): queue follow-ups during active responses --- frontend/src/components/UnifiedChat.tsx | 1904 ++++++++++++----- .../src/contexts/ChatRuntimeContext.test.ts | 150 +- frontend/src/contexts/ChatRuntimeContext.tsx | 29 +- .../services/chatAccountQueueBudget.test.ts | 170 ++ .../src/services/chatAccountQueueBudget.ts | 118 + .../src/services/chatComposerQueue.test.ts | 707 ++++++ frontend/src/services/chatComposerQueue.ts | 681 ++++++ .../src/services/chatComposerSend.test.ts | 303 +++ frontend/src/services/chatComposerSend.ts | 173 ++ .../services/chatCurrentTurnRegistry.test.ts | 91 + .../src/services/chatCurrentTurnRegistry.ts | 81 + frontend/src/services/chatPollingPage.test.ts | 30 + frontend/src/services/chatPollingPage.ts | 19 + .../src/services/chatResponseErrors.test.ts | 80 +- frontend/src/services/chatResponseErrors.ts | 57 +- .../chatResponseReconciliation.test.ts | 41 + .../services/chatResponseReconciliation.ts | 36 + .../src/services/chatRunQueueHalt.test.ts | 31 + frontend/src/services/chatRunQueueHalt.ts | 24 + .../src/services/chatRuntimeCancellation.ts | 23 + .../services/chatRuntimeDeletionFence.test.ts | 123 ++ .../src/services/chatRuntimeDeletionFence.ts | 108 + .../src/services/chatRuntimeStore.test.ts | 69 + frontend/src/services/chatRuntimeStore.ts | 59 +- .../services/chatSendFailureRecovery.test.ts | 51 +- .../src/services/chatSendFailureRecovery.ts | 15 + .../chatStoppingRuntimeRegistry.test.ts | 45 + .../services/chatStoppingRuntimeRegistry.ts | 62 + .../chatUnresolvedResponseOwnership.test.ts | 39 + .../chatUnresolvedResponseOwnership.ts | 41 + 30 files changed, 4813 insertions(+), 547 deletions(-) create mode 100644 frontend/src/services/chatAccountQueueBudget.test.ts create mode 100644 frontend/src/services/chatAccountQueueBudget.ts create mode 100644 frontend/src/services/chatComposerQueue.test.ts create mode 100644 frontend/src/services/chatComposerQueue.ts create mode 100644 frontend/src/services/chatComposerSend.test.ts create mode 100644 frontend/src/services/chatComposerSend.ts create mode 100644 frontend/src/services/chatCurrentTurnRegistry.test.ts create mode 100644 frontend/src/services/chatCurrentTurnRegistry.ts create mode 100644 frontend/src/services/chatPollingPage.test.ts create mode 100644 frontend/src/services/chatPollingPage.ts create mode 100644 frontend/src/services/chatResponseReconciliation.test.ts create mode 100644 frontend/src/services/chatResponseReconciliation.ts create mode 100644 frontend/src/services/chatRunQueueHalt.test.ts create mode 100644 frontend/src/services/chatRunQueueHalt.ts create mode 100644 frontend/src/services/chatRuntimeCancellation.ts create mode 100644 frontend/src/services/chatRuntimeDeletionFence.test.ts create mode 100644 frontend/src/services/chatRuntimeDeletionFence.ts create mode 100644 frontend/src/services/chatStoppingRuntimeRegistry.test.ts create mode 100644 frontend/src/services/chatStoppingRuntimeRegistry.ts create mode 100644 frontend/src/services/chatUnresolvedResponseOwnership.test.ts create mode 100644 frontend/src/services/chatUnresolvedResponseOwnership.ts diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index fb763fde7..2cb30d0ef 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -6,7 +6,8 @@ import { useCallback, memo, useMemo, - useId + useId, + useSyncExternalStore } from "react"; import { flushSync } from "react-dom"; import { @@ -60,6 +61,11 @@ import { ChatDesktopConversationHeader, ChatUserTurn } from "@/components/chat/ChatTurn"; +import { + DiscardQueuedMessageEditButton, + QUEUED_MESSAGE_EDIT_PLACEHOLDER, + QueuedComposerMessages +} from "@/components/chat/QueuedComposerMessages"; import { ChatCopyButton } from "@/components/chat/ChatCopyButton"; import { ToolActivityCard } from "@/components/ToolActivityCard"; import { @@ -161,23 +167,69 @@ import { cleanupRecordingForTeardown, isRecordingOwnershipCurrent } from "@/services/chatRecordingNavigation"; -import { - canAdoptAttachmentDestination, - mutateAttachmentComposerWhenIdle, - planRestoredImageUrls -} from "@/services/chatAttachmentOwnership"; +import { canAdoptAttachmentDestination } from "@/services/chatAttachmentOwnership"; import { classifyChatStreamEof, createChatStreamDeltaCoalescer, flushRegisteredChatStreamDeltas, isTerminalChatStreamErrorEvent, registerChatStreamDeltaCoalescer, - removeOwnedChatStreamAttemptItems, unregisterChatStreamDeltaCoalescer, type ChatStreamTerminalState } from "@/services/chatStreamDeltaCoalescer"; -import { recoverFailedSendAfterDestinationAdoption } from "@/services/chatSendFailureRecovery"; -import { isImageDescriptionUnavailableError } from "@/services/chatResponseErrors"; +import { + isChatRequestDefinitelyNotDispatchedError, + isChatResponseCancellationAlreadyTerminalError, + isChatResponseDefinitelyRejectedError, + isImageDescriptionUnavailableError +} from "@/services/chatResponseErrors"; +import { isChatAccountCredentialMismatchError } from "@/services/chatAccountCredential"; +import { chatCursorAfterSendFailure } from "@/services/chatSendFailureRecovery"; +import { normalizeChatPollingPage } from "@/services/chatPollingPage"; +import { + classifyChatResponseReconciliation, + responseIdForChatMessage +} from "@/services/chatResponseReconciliation"; +import { + clearUnresolvedChatResponseMessage, + getUnresolvedChatResponseMessage, + registerUnresolvedChatResponseMessage +} from "@/services/chatUnresolvedResponseOwnership"; +import { + chatAccountQueueUsage, + selectChatImageFilesForRetention +} from "@/services/chatAccountQueueBudget"; +import { isChatRuntimeDeletionPending } from "@/services/chatRuntimeDeletionFence"; +import { + clearChatRunQueueHalt, + isChatRunQueueHaltRequested, + requestChatRunQueueHalt +} from "@/services/chatRunQueueHalt"; +import { chatStoppingRuntimeRegistryFor } from "@/services/chatStoppingRuntimeRegistry"; +import { + registerChatCurrentTurn, + restoreRegisteredChatTurnBeforeRequest +} from "@/services/chatCurrentTurnRegistry"; +import { + beginChatQueuedMessageEdit, + cancelChatQueuedMessage, + chatComposerObjectUrls, + chatQueuedTextByteLength, + discardChatQueuedMessageEdit, + MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES, + MAX_CHAT_ACCOUNT_RETAINED_IMAGES, + mergeChatComposerDraftsForRekey, + recoverDetachedChatComposerDraft, + takeNextChatQueuedMessage, + type ChatQueuedMessage, + type ChatQueuedMessageMetadata +} from "@/services/chatComposerQueue"; +import { + canSubmitChatComposer, + chatComposerWithInputOverride, + chatComposerShowsStop, + planChatComposerSubmission +} from "@/services/chatComposerSend"; import { chatToolCallStatus, chatToolOutputStatus, @@ -195,6 +247,12 @@ import { toolKindFromName } from "@/services/toolPresentation"; const CHAT_ALERT_CLASS = "absolute top-16 left-1/2 z-50 w-full max-w-2xl -translate-x-1/2 px-4"; const STREAM_EVENT_DEBUG_STORAGE_KEY = "maple:sse-debug"; +const CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE = + "Chat drafts and queued attachments can use up to 256 MiB across your account"; +const CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE = `Chat drafts and queued messages can retain up to ${MAX_CHAT_ACCOUNT_RETAINED_IMAGES} images across your account`; +const CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE = "You can attach up to 10 images to a message"; +const CHAT_STOP_WAITING_MESSAGE = "Stopping as soon as the response is ready…"; +const CHAT_STOP_REQUEST_TIMEOUT_MS = 5000; function isStreamEventDebugLoggingEnabled(): boolean { if (!import.meta.env.DEV || typeof window === "undefined") return false; @@ -271,6 +329,75 @@ type Message = | ToolOutputItem | ReasoningItem; +function queuedChatMessageText(item: ChatQueuedMessage): string { + return item.documentText + (item.documentText && item.text ? `\n\n${item.text}` : item.text); +} + +function queuedChatMessageContent( + item: ChatQueuedMessage, + imageUrlForFile: (file: File) => string | undefined +): (InputTextContent | InputImageContent)[] { + const content: (InputTextContent | InputImageContent)[] = []; + const text = queuedChatMessageText(item); + if (text) content.push({ type: "input_text", text }); + for (const file of item.draftImages) { + const imageUrl = imageUrlForFile(file); + if (!imageUrl) continue; + content.push({ + type: "input_image", + image_url: imageUrl, + detail: "auto", + file_id: null + }); + } + return content; +} + +function promotedChatUserMessage( + item: ChatQueuedMessage, + content = queuedChatMessageContent(item, (file) => item.imageUrls.get(file)) +): Message { + return { + id: item.messageId, + type: "message", + role: "user", + content, + status: "completed" + } as unknown as Message; +} + +function queuedChatMessageFallbackLabel(item: ChatQueuedMessage): string { + if (item.documentName) return item.documentName; + const imageCount = item.draftImages.length; + return imageCount === 1 ? "1 image" : imageCount > 1 ? `${imageCount} images` : "Queued message"; +} + +function chatTranscriptObjectUrls(messages: readonly Message[]): string[] { + const urls = new Set(); + for (const message of messages) { + if (message.type !== "message" || !Array.isArray(message.content)) continue; + for (const content of message.content) { + if ( + content.type === "input_image" && + typeof content.image_url === "string" && + content.image_url.startsWith("blob:") + ) { + urls.add(content.image_url); + } + } + } + return Array.from(urls); +} + +function revokeQueuedChatMessageObjectUrls( + item: ChatQueuedMessage, + retainedObjectUrls: ReadonlySet = new Set() +): void { + for (const url of new Set(item.imageUrls.values())) { + if (!retainedObjectUrls.has(url)) URL.revokeObjectURL(url); + } +} + // Helper function to merge messages while ensuring uniqueness by ID // This prevents duplicate key warnings in React by deduplicating messages function mergeMessagesById(existingMessages: Message[], newMessages: Message[]): Message[] { @@ -688,7 +815,7 @@ function summarizeStreamEventForLog(eventType: string, event: unknown): Record ): Message[] { const updatedMessages = messages @@ -1412,6 +1539,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const runtimeStore = useChatRuntimeStore(); const runtimeInstanceId = useId(); const visibleChatOwner = useRef({}).current; + const responseReconciliationsInFlightRef = useRef(new Set()); const [initialRuntimeSelection] = useState(() => { const params = new URLSearchParams(window.location.search); @@ -1515,18 +1643,17 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { [runtimeStore] ); - const updateIdleAttachmentComposerForKey = useCallback( + const updateAttachmentComposerForKey = useCallback( (key: ChatRuntimeKey, updater: (composer: ChatComposerState) => ChatComposerState) => { - const startSnapshot = runtimeStore.get(key); - if (!startSnapshot || startSnapshot.isGenerating) return false; - - const result = mutateAttachmentComposerWhenIdle(startSnapshot, updater); - if (!result.didMutate) return false; - runtimeStore.update(key, (snapshot) => ({ - ...snapshot, - composer: result.composer - })); - return true; + if (!runtimeStore.get(key)) return false; + let didMutate = false; + runtimeStore.update(key, (snapshot) => { + if (snapshot.composer.queue.edit) return snapshot; + const composer = updater(snapshot.composer); + didMutate = composer !== snapshot.composer; + return didMutate ? { ...snapshot, composer } : snapshot; + }); + return didMutate; }, [runtimeStore] ); @@ -1538,6 +1665,22 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const input = activeRuntime.composer.input; const draftProjectId = activeRuntime.composer.draftProjectId; const isGenerating = activeRuntime.isGenerating; + const queuedMessages = activeRuntime.composer.queue.items; + const queueEdit = activeRuntime.composer.queue.edit; + const stoppingRuntimeRegistry = useMemo( + () => chatStoppingRuntimeRegistryFor(runtimeStore), + [runtimeStore] + ); + useSyncExternalStore( + stoppingRuntimeRegistry.subscribe, + stoppingRuntimeRegistry.getSnapshot, + stoppingRuntimeRegistry.getSnapshot + ); + const isStopping = Array.from(stoppingRuntimeRegistry.getEntries()).some( + ([key, runTokens]) => + runTokens.size > 0 && + runtimeStore.resolveKey(key) === runtimeStore.resolveKey(activeRuntime.key) + ); const [isSidebarOpen, setIsSidebarOpen] = usePersistentSidebarState(isCompactLayout); const [isSidebarTransitioning, setIsSidebarTransitioning] = useState(false); const error = activeRuntime.error; @@ -1561,6 +1704,21 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { attachmentError, audioError } = activeRuntime.composer; + const editedQueuedMessage = queueEdit + ? queuedMessages.find((item) => item.queueId === queueEdit.queueId) + : undefined; + const canSubmitMessage = canSubmitChatComposer({ + text: input, + hasAttachments: queueEdit + ? Boolean(editedQueuedMessage?.draftImages.length || editedQueuedMessage?.documentText) + : Boolean(draftImages.length || documentText), + hasQueuedMessages: queuedMessages.length > 0, + isEditingQueuedMessage: Boolean(queueEdit), + hasActiveRun: isGenerating, + isProcessingDocument, + isStopping + }); + const showsStop = chatComposerShowsStop(isGenerating, isStopping); const setConversationForKey = useCallback( (key: ChatRuntimeKey, update: StateUpdate) => { @@ -2424,44 +2582,214 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { }; }, [chatId, runtimeStore, selectConversationRuntime, selectFreshDraftRuntime, selectedProjectId]); + const settleLocallyCancelledRun = useCallback( + (runtimeKey: ChatRuntimeKey, runToken: number, optimisticMessageId: string | undefined) => { + const cancelled = runtimeStore.cancelRun(runtimeKey, runToken); + if (!cancelled) return false; + clearUnresolvedChatResponseMessage(runtimeStore, runToken); + runtimeStore.update(runtimeKey, (snapshot) => ({ + ...snapshot, + error: snapshot.error === CHAT_STOP_WAITING_MESSAGE ? null : snapshot.error, + messages: cancelled.responseId + ? updateActiveItemStatuses(snapshot.messages as Message[], "incomplete") + : updateActiveItemStatuses( + markOptimisticMessageIncomplete(snapshot.messages as Message[], optimisticMessageId), + "incomplete" + ) + })); + if (optimisticMessageId) { + unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId); + } + return true; + }, + [runtimeStore] + ); + + const cancelKnownChatResponse = useCallback( + async ( + runtimeKey: ChatRuntimeKey, + runToken: number, + responseId: string, + optimisticMessageId: string | undefined + ): Promise => { + if (!openai) return false; + try { + await ( + openai.responses as { + cancel: (id: string, options?: { timeout?: number }) => Promise; + } + ).cancel(responseId, { timeout: CHAT_STOP_REQUEST_TIMEOUT_MS }); + return settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId); + } catch (error) { + console.error("Failed to cancel response:", error); + if (!isChatResponseCancellationAlreadyTerminalError(error)) { + if (runtimeStore.isRunCurrent(runtimeKey, runToken)) { + setErrorForKey(runtimeKey, "Failed to cancel response. Please try Stop again."); + } + return false; + } + try { + const response = await openai.responses.retrieve(responseId, undefined, { + timeout: CHAT_STOP_REQUEST_TIMEOUT_MS + }); + const status = (response as { status?: string }).status; + const runIsCurrent = runtimeStore.isRunCurrent(runtimeKey, runToken); + + if (status === "completed") { + // Stop intent is sticky across this reconciliation. Settle now even + // if a buffered stream is healthy so the outer loop cannot promote + // a queued turn after this helper releases the Stop UI fence. The + // persisted user/item cursor remains valid for ascending polling. + if (runIsCurrent) { + runtimeStore.completeRunAndAbort(runtimeKey, runToken, (owned) => ({ + ...owned, + messages: updateActiveItemStatuses(owned.messages as Message[], "completed"), + error: "The response finished before it could be stopped." + })); + clearUnresolvedChatResponseMessage(runtimeStore, runToken); + return true; + } + return false; + } + + if (status === "failed" || status === "cancelled" || status === "incomplete") { + return settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId); + } + } catch (reconciliationError) { + console.error( + "Failed to reconcile response after cancellation error:", + reconciliationError + ); + } + + if (runtimeStore.isRunCurrent(runtimeKey, runToken)) { + setErrorForKey(runtimeKey, "Failed to cancel response. Please try Stop again."); + } + return false; + } finally { + // Keep this fence through cancellation/retrieval so the send loop + // cannot promote a later FIFO item under the same outer run token. + stoppingRuntimeRegistry.delete(runtimeKey, runToken); + if (!runtimeStore.isRunCurrent(runtimeKey, runToken)) { + clearChatRunQueueHalt(runtimeStore, runToken); + } + } + }, + [openai, runtimeStore, setErrorForKey, settleLocallyCancelledRun, stoppingRuntimeRegistry] + ); + + const reconcileDetachedChatResponse = useCallback( + async (runtimeKey: ChatRuntimeKey) => { + const snapshot = runtimeStore.get(runtimeKey); + if ( + !openai || + !snapshot?.isGenerating || + snapshot.runToken === null || + !snapshot.currentResponseId + ) { + return; + } + + const runToken = snapshot.runToken; + const responseId = snapshot.currentResponseId; + const reconciliationKey = `${runtimeStore.resolveKey(runtimeKey)}:${runToken}:${responseId}`; + if (responseReconciliationsInFlightRef.current.has(reconciliationKey)) return; + responseReconciliationsInFlightRef.current.add(reconciliationKey); + + try { + if (isChatRunQueueHaltRequested(runtimeStore, runToken)) { + await cancelKnownChatResponse(runtimeKey, runToken, responseId, undefined); + return; + } + const response = await openai.responses.retrieve(responseId, undefined, { + timeout: CHAT_STOP_REQUEST_TIMEOUT_MS + }); + const current = runtimeStore.get(runtimeKey); + if (!current || current.runToken !== runToken || current.currentResponseId !== responseId) { + return; + } + + const resolution = classifyChatResponseReconciliation( + (response as { status?: string | null }).status + ); + if (resolution === "completed") { + runtimeStore.completeRunAndAbort(runtimeKey, runToken, (owned) => ({ + ...owned, + messages: updateActiveItemStatuses(owned.messages as Message[], "completed"), + error: + "The response completed after the connection was restored. Queued messages were kept." + })); + clearUnresolvedChatResponseMessage(runtimeStore, runToken); + stoppingRuntimeRegistry.delete(runtimeKey, runToken); + clearChatRunQueueHalt(runtimeStore, runToken); + } else if (resolution === "terminal") { + if (settleLocallyCancelledRun(runtimeKey, runToken, undefined)) { + setErrorForKey( + runtimeKey, + "The interrupted response stopped. Queued messages were kept." + ); + stoppingRuntimeRegistry.delete(runtimeKey, runToken); + clearChatRunQueueHalt(runtimeStore, runToken); + } + } + } catch (error) { + console.error("Failed to reconcile interrupted response:", error); + } finally { + responseReconciliationsInFlightRef.current.delete(reconciliationKey); + } + }, + [ + cancelKnownChatResponse, + openai, + runtimeStore, + setErrorForKey, + settleLocallyCancelledRun, + stoppingRuntimeRegistry + ] + ); + // Cancel the current response const handleCancelResponse = useCallback(async () => { - const runtimeKey = activeRuntimeKeyRef.current; - const runToken = runtimeStore.get(runtimeKey)?.runToken; - if (runToken === null || runToken === undefined) return; + const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current); + const runSnapshot = runtimeStore.get(runtimeKey); + if (!runSnapshot || runSnapshot.runToken === null) return; + const runToken = runSnapshot.runToken; + stoppingRuntimeRegistry.add(runtimeKey, runToken); + requestChatRunQueueHalt(runtimeStore, runToken); const optimisticMessageId = getRegisteredChatOptimisticMessage(runtimeStore, runToken); + const restoredBeforeRequest = restoreRegisteredChatTurnBeforeRequest( + runtimeStore, + runToken, + "Stopped before sending. Your message was restored." + ); // Commit the final partial frame while this run still owns its token. Once // cancelRun clears ownership, any delayed callback must fail closed. flushRegisteredChatStreamDeltas(runtimeStore, runToken); - const cancelled = runtimeStore.cancelRun(runtimeKey, runToken); - if (!cancelled) return; - - runtimeStore.update(runtimeKey, (snapshot) => ({ - ...snapshot, - messages: cancelled.responseId - ? updateActiveItemStatuses(snapshot.messages as Message[], "incomplete") - : updateActiveItemStatuses( - markOptimisticMessageIncomplete(snapshot.messages as Message[], optimisticMessageId), - "incomplete" - ) - })); - if (optimisticMessageId) { - unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId); + + if (restoredBeforeRequest) { + settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId); + stoppingRuntimeRegistry.delete(runtimeKey, runToken); + return; } - try { - if (cancelled.responseId && openai) { - await (openai.responses as { cancel: (id: string) => Promise }).cancel( - cancelled.responseId - ); - } - } catch (error) { - console.error("Failed to cancel response:", error); - if (runtimeStore.get(runtimeKey)) { - setErrorForKey(runtimeKey, "Failed to cancel response. Please try again."); - } + const currentResponseId = runtimeStore.get(runtimeKey)?.currentResponseId; + if (currentResponseId) { + await cancelKnownChatResponse(runtimeKey, runToken, currentResponseId, optimisticMessageId); + return; } - }, [openai, runtimeStore, setErrorForKey]); + + // The POST may already be accepted even though response.created has not + // arrived. Preserve the run and Stop fence; the stream handler will cancel + // immediately when it learns the server response ID. Local cancellation + // here would orphan server work and allow the FIFO to overlap it. + setErrorForKey(runtimeKey, CHAT_STOP_WAITING_MESSAGE); + }, [ + cancelKnownChatResponse, + runtimeStore, + setErrorForKey, + settleLocallyCancelledRun, + stoppingRuntimeRegistry + ]); // Load conversation from API const loadConversation = useCallback( @@ -2513,6 +2841,32 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const newestCompletedItem = itemsResponse.data.find( (item) => (item as Message).status !== "in_progress" ); + const snapshotBeforeItems = runtimeStore.get(runtimeKey); + if ( + snapshotBeforeItems?.isGenerating && + !snapshotBeforeItems.currentResponseId && + snapshotBeforeItems.runToken !== null + ) { + const unresolvedMessageId = getUnresolvedChatResponseMessage( + runtimeStore, + snapshotBeforeItems.runToken + ); + const recoveredResponseId = unresolvedMessageId + ? responseIdForChatMessage(unresolvedMessageId, itemsResponse.data) + : undefined; + if (recoveredResponseId) { + runtimeStore.setCurrentResponseId( + runtimeKey, + snapshotBeforeItems.runToken, + recoveredResponseId + ); + clearUnresolvedChatResponseMessage( + runtimeStore, + snapshotBeforeItems.runToken, + unresolvedMessageId + ); + } + } runtimeStore.update(runtimeKey, (snapshot) => ({ ...snapshot, messages: mergeLoadedMessagesWithRuntime(messagesInChronologicalOrder, snapshot.messages), @@ -2719,20 +3073,88 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const conversationId = snapshot?.conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeStore.resolveKey(runtimeKey)); - if (!snapshot || !conversationId || !openai || snapshot.assistantStreaming) return; + if ( + !snapshot || + !conversationId || + !openai || + (snapshot.assistantStreaming && snapshot.currentResponseId) + ) { + return; + } try { + if (!snapshot.currentResponseId && snapshot.runToken !== null) { + const unresolvedMessageId = getUnresolvedChatResponseMessage( + runtimeStore, + snapshot.runToken + ); + // Retrieve the exact persisted user item. A list page can contain ten + // image-description call/output pairs before the user item and is not + // a reliable response-ownership lookup. + if (unresolvedMessageId) { + try { + const linkedItem = await openai.conversations.items.retrieve(unresolvedMessageId, { + conversation_id: conversationId + }); + const recoveredResponseId = responseIdForChatMessage(unresolvedMessageId, [ + linkedItem + ]); + if ( + recoveredResponseId && + runtimeStore.setCurrentResponseId( + runtimeKey, + snapshot.runToken, + recoveredResponseId + ) + ) { + clearUnresolvedChatResponseMessage( + runtimeStore, + snapshot.runToken, + unresolvedMessageId + ); + } + } catch (error) { + const status = (error as { status?: unknown })?.status; + if (status !== 404) console.error("Response ownership polling error:", error); + } + } + } + // Fetch NEW items that came after the last seen ID // Use order=asc to get items chronologically after the lastSeenItemId + const hasCursor = Boolean(snapshot.lastSeenItemId); const response = await openai.conversations.items.list(conversationId, { - ...(snapshot.lastSeenItemId ? { after: snapshot.lastSeenItemId, order: "asc" } : {}), + ...(snapshot.lastSeenItemId ? { after: snapshot.lastSeenItemId } : {}), + order: hasCursor ? "asc" : "desc", limit: 20 // Smaller limit since we only expect a few new messages }); + const pollingPage = normalizeChatPollingPage(response.data, hasCursor); + + if (!snapshot.currentResponseId && snapshot.runToken !== null) { + const unresolvedMessageId = getUnresolvedChatResponseMessage( + runtimeStore, + snapshot.runToken + ); + const recoveredResponseId = unresolvedMessageId + ? responseIdForChatMessage(unresolvedMessageId, response.data) + : undefined; + if (recoveredResponseId) { + if ( + runtimeStore.setCurrentResponseId(runtimeKey, snapshot.runToken, recoveredResponseId) + ) { + clearUnresolvedChatResponseMessage( + runtimeStore, + snapshot.runToken, + unresolvedMessageId + ); + } + } + } if (response.data.length > 0) { // Convert API items to UI messages, grouping tool calls with their messages const newMessages = convertItemsToMessages( - response.data as Array<{ + pollingPage.chronologicalItems as Array<{ id: string; type: string; role?: string; @@ -2767,10 +3189,8 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { // Update last seen item ID for next poll // Since we're using order=asc, the LAST item is the newest // Skip in_progress messages by finding the last completed one - const newestCompletedItem = [...response.data] - .reverse() - .find((item) => (item as Message).status !== "in_progress"); - if (newestCompletedItem) { + const newestCompletedItem = pollingPage.newestCompletedItem; + if (newestCompletedItem?.id) { setLastSeenItemIdForKey(runtimeKey, newestCompletedItem.id); } } @@ -2779,8 +3199,28 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { console.error("Polling error:", error); // Don't throw - polling should fail silently } + if (!runtimeStore.get(runtimeKey)?.conversation) { + try { + const conversation = (await openai.conversations.retrieve( + conversationId + )) as Conversation; + if (runtimeStore.get(runtimeKey)) { + runtimeStore.update(runtimeKey, (current) => ({ ...current, conversation })); + runtimeStore.updateActivityGroup(runtimeKey, conversation.project_id ?? null); + } + } catch (error) { + console.error("Conversation metadata polling error:", error); + } + } + await reconcileDetachedChatResponse(runtimeKey); }, - [isRuntimeSelected, openai, runtimeStore, setLastSeenItemIdForKey] + [ + isRuntimeSelected, + openai, + reconcileDetachedChatResponse, + runtimeStore, + setLastSeenItemIdForKey + ] ); // Load conversation when URL changes or on mount. Cached runtimes—including @@ -2795,8 +3235,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { }, [activeRuntimeKey, chatId, openai, loadConversation, runtimeStore]); // Set up progressive polling interval + const pollingConversationId = + conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeStore.resolveKey(activeRuntimeKey)); + useEffect(() => { - if (!conversation?.id || !openai) return; + if (!pollingConversationId || !openai) return; const runtimeKey = activeRuntimeKey; // Progressive intervals: 2s, 5s, 10s, 15s, 20s, 30s, 60s (then 60s forever) @@ -2827,7 +3270,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { return () => { if (timeoutId) clearTimeout(timeoutId); }; - }, [activeRuntimeKey, conversation?.id, openai, pollForNewItems]); + }, [activeRuntimeKey, openai, pollForNewItems, pollingConversationId]); // Poll for title updates when it's "New Conversation" with exponential backoff useEffect(() => { @@ -3400,7 +3843,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const selectedFiles = Array.from(e.currentTarget.files ?? []); e.currentTarget.value = ""; const ownerSnapshot = runtimeStore.get(ownerKey); - if (selectedFiles.length === 0 || !ownerSnapshot || ownerSnapshot.isGenerating) return; + if (selectedFiles.length === 0 || !ownerSnapshot || ownerSnapshot.composer.queue.edit) return; const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; const maxSizeInBytes = 20 * 1024 * 1024; @@ -3417,26 +3860,50 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } return true; }); - if (validationError) { + if (validFiles.length === 0) { + if (validationError) { + setComposerErrorForKey(ownerKey, "attachmentError", validationError); + } + return; + } + const accountUsage = chatAccountQueueUsage(runtimeStore); + const selection = selectChatImageFilesForRetention({ + composer: ownerSnapshot.composer, + candidates: validFiles, + accountUsage + }); + if (selection.accountLimitExceeded) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE); + } else if (selection.messageLimitExceeded) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE); + } else if (validationError) { setComposerErrorForKey(ownerKey, "attachmentError", validationError); } - if (validFiles.length === 0) return; + if (selection.files.length === 0) return; + const additionalBytes = selection.files.reduce((total, file) => total + file.size, 0); + if ( + accountUsage.attachmentBytes + additionalBytes > + MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES + ) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE); + return; + } - const newUrls = validFiles.map((file) => [file, URL.createObjectURL(file)] as const); - const attached = updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({ + const newUrls = selection.files.map((file) => [file, URL.createObjectURL(file)] as const); + const attached = updateAttachmentComposerForKey(ownerKey, (composer) => ({ ...composer, imageUrls: new Map([...composer.imageUrls, ...newUrls]), - draftImages: [...composer.draftImages, ...validFiles] + draftImages: [...composer.draftImages, ...selection.files] })); if (!attached) for (const [, url] of newUrls) URL.revokeObjectURL(url); }, - [runtimeStore, setComposerErrorForKey, updateIdleAttachmentComposerForKey] + [runtimeStore, setComposerErrorForKey, updateAttachmentComposerForKey] ); const attachPastedImages = useCallback( (imageFiles: File[], ownerKey: ChatRuntimeKey, expectedGeneration: number) => { const ownerSnapshot = runtimeStore.get(ownerKey); - if (!ownerSnapshot || ownerSnapshot.isGenerating) return; + if (!ownerSnapshot || ownerSnapshot.composer.queue.edit) return; if (!canUseImages) { if (isRuntimeSelected(ownerKey)) { @@ -3461,21 +3928,44 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } return true; }); - if (validationError) { + if (validFiles.length === 0) { + if (validationError) { + setComposerErrorForKey(ownerKey, "attachmentError", validationError); + } + return; + } + const accountUsage = chatAccountQueueUsage(runtimeStore); + const selection = selectChatImageFilesForRetention({ + composer: ownerSnapshot.composer, + candidates: validFiles, + accountUsage + }); + if (selection.accountLimitExceeded) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE); + } else if (selection.messageLimitExceeded) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE); + } else if (validationError) { setComposerErrorForKey(ownerKey, "attachmentError", validationError); } + if (selection.files.length === 0) return; + const additionalBytes = selection.files.reduce((total, file) => total + file.size, 0); + if ( + accountUsage.attachmentBytes + additionalBytes > + MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES + ) { + setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE); + return; + } - if (validFiles.length === 0) return; - - const newUrls = validFiles.map((file) => [file, URL.createObjectURL(file)] as const); + const newUrls = selection.files.map((file) => [file, URL.createObjectURL(file)] as const); let generationMatched = false; - const attached = updateIdleAttachmentComposerForKey(ownerKey, (composer) => { + const attached = updateAttachmentComposerForKey(ownerKey, (composer) => { if (composer.imagePasteGeneration !== expectedGeneration) return composer; generationMatched = true; return { ...composer, imageUrls: new Map([...composer.imageUrls, ...newUrls]), - draftImages: [...composer.draftImages, ...validFiles] + draftImages: [...composer.draftImages, ...selection.files] }; }); if (!attached || !generationMatched) { @@ -3487,7 +3977,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { isRuntimeSelected, runtimeStore, setComposerErrorForKey, - updateIdleAttachmentComposerForKey + updateAttachmentComposerForKey ] ); @@ -3495,7 +3985,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { (e: React.ClipboardEvent) => { const ownerKey = activeRuntimeKeyRef.current; const startSnapshot = runtimeStore.get(ownerKey); - if (!startSnapshot || startSnapshot.isGenerating) return; + if (!startSnapshot || startSnapshot.composer.queue.edit) return; const pasteGeneration = startSnapshot.composer.imagePasteGeneration + 1; updateComposerForKey(ownerKey, (composer) => ({ ...composer, @@ -3550,11 +4040,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { (idx: number) => { const ownerKey = activeRuntimeKeyRef.current; const snapshot = runtimeStore.get(ownerKey); - if (!snapshot || snapshot.isGenerating) return; + if (!snapshot || snapshot.composer.queue.edit) return; const fileToRemove = snapshot?.composer.draftImages[idx]; if (!fileToRemove) return; const url = snapshot.composer.imageUrls.get(fileToRemove); - const removed = updateIdleAttachmentComposerForKey(ownerKey, (composer) => { + const removed = updateAttachmentComposerForKey(ownerKey, (composer) => { const nextUrls = new Map(composer.imageUrls); nextUrls.delete(fileToRemove); return { @@ -3565,7 +4055,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { }); if (removed && url) URL.revokeObjectURL(url); }, - [runtimeStore, updateIdleAttachmentComposerForKey] + [runtimeStore, updateAttachmentComposerForKey] ); const handleDocumentUpload = useCallback( @@ -3577,7 +4067,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { if (!file) return; const startSnapshot = runtimeStore.get(ownerKey); - if (!startSnapshot || startSnapshot.isGenerating) { + if (!startSnapshot || startSnapshot.composer.queue.edit) { inputElement.value = ""; return; } @@ -3590,7 +4080,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } const uploadGeneration = startSnapshot.composer.documentUploadGeneration + 1; - const started = updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({ + const started = updateAttachmentComposerForKey(ownerKey, (composer) => ({ ...composer, isProcessingDocument: true, attachmentError: null, @@ -3605,13 +4095,28 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { updater: (composer: ChatComposerState) => ChatComposerState ) => { let generationMatched = false; - const updated = updateIdleAttachmentComposerForKey(ownerKey, (composer) => { + const updated = updateAttachmentComposerForKey(ownerKey, (composer) => { if (composer.documentUploadGeneration !== uploadGeneration) return composer; generationMatched = true; return updater(composer); }); return updated && generationMatched; }; + const retainDocumentIfWithinAccountBudget = (documentText: string, documentName: string) => + updateDocumentIfCurrent((composer) => { + const accountUsage = chatAccountQueueUsage(runtimeStore); + const nextAttachmentBytes = + accountUsage.attachmentBytes - + chatQueuedTextByteLength(composer.documentText) + + chatQueuedTextByteLength(documentText); + if (nextAttachmentBytes > MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES) { + return { + ...composer, + attachmentError: CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE + }; + } + return { ...composer, documentText, documentName }; + }); try { const documentType = getSupportedDocumentType(file.name); @@ -3624,11 +4129,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { text_content: text } }; - updateDocumentIfCurrent((composer) => ({ - ...composer, - documentText: JSON.stringify(documentData), - documentName: file.name - })); + retainDocumentIfWithinAccountBudget(JSON.stringify(documentData), file.name); } else if (documentType && isNativeDocumentType(documentType) && isTauriEnv) { const result = await extractDocumentContent(file, documentType); if (runtimeStore.get(ownerKey)?.composer.documentUploadGeneration !== uploadGeneration) @@ -3656,11 +4157,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } }; - updateDocumentIfCurrent((composer) => ({ - ...composer, - documentText: JSON.stringify(cleanedParsed), - documentName: file.name - })); + retainDocumentIfWithinAccountBudget(JSON.stringify(cleanedParsed), file.name); } else if (documentType && isNativeDocumentType(documentType)) { setComposerErrorForKey( ownerKey, @@ -3684,26 +4181,36 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { ); } } finally { - updateDocumentIfCurrent((composer) => ({ - ...composer, - isProcessingDocument: false - })); + // An edit may begin while native document extraction is in flight. + // Attachment mutation stays blocked during the edit, but the owned + // processing flag must always settle so the composer cannot deadlock. + updateComposerForKey(ownerKey, (composer) => + composer.documentUploadGeneration === uploadGeneration + ? { ...composer, isProcessingDocument: false } + : composer + ); inputElement.value = ""; } }, - [isTauriEnv, runtimeStore, setComposerErrorForKey, updateIdleAttachmentComposerForKey] + [ + isTauriEnv, + runtimeStore, + setComposerErrorForKey, + updateAttachmentComposerForKey, + updateComposerForKey + ] ); const removeDocument = useCallback(() => { const ownerKey = activeRuntimeKeyRef.current; - updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({ + updateAttachmentComposerForKey(ownerKey, (composer) => ({ ...composer, isProcessingDocument: false, documentText: "", documentName: "", documentUploadGeneration: composer.documentUploadGeneration + 1 })); - }, [updateIdleAttachmentComposerForKey]); + }, [updateAttachmentComposerForKey]); // Audio recording functions const startRecording = async () => { @@ -3972,14 +4479,102 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } }; - // Helper function to process streaming response - used by both initial request and retry + const discardQueueEdit = useCallback(() => { + const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current); + if (!runtimeStore.get(runtimeKey)) return; + runtimeStore.update(runtimeKey, (snapshot) => { + const discarded = discardChatQueuedMessageEdit(snapshot.composer.queue); + if (discarded.status !== "ended") return snapshot; + return { + ...snapshot, + composer: { + ...snapshot.composer, + input: discarded.restoreInput, + queue: discarded.queue + } + }; + }); + }, [runtimeStore]); + + const cancelQueuedMessage = useCallback( + (queueId: string) => { + const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current); + let removedItem: ChatQueuedMessage | null = null; + let retainedObjectUrls = new Set(); + if (!runtimeStore.get(runtimeKey)) return; + runtimeStore.update(runtimeKey, (snapshot) => { + const cancelled = cancelChatQueuedMessage(snapshot.composer.queue, queueId); + if (cancelled.status !== "cancelled") return snapshot; + removedItem = cancelled.item; + const composer = { + ...snapshot.composer, + input: cancelled.restoreInput ?? snapshot.composer.input, + queue: cancelled.queue + }; + retainedObjectUrls = new Set([ + ...chatComposerObjectUrls(composer), + ...chatTranscriptObjectUrls(snapshot.messages as Message[]) + ]); + return { + ...snapshot, + composer + }; + }); + if (removedItem) { + revokeQueuedChatMessageObjectUrls(removedItem, retainedObjectUrls); + } + }, + [runtimeStore] + ); + + const editQueuedMessage = useCallback( + (queueId: string) => { + const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current); + let shouldFocus = false; + if (!runtimeStore.get(runtimeKey)) return; + runtimeStore.update(runtimeKey, (snapshot) => { + const started = beginChatQueuedMessageEdit( + snapshot.composer.queue, + runtimeKey, + queueId, + snapshot.composer.input + ); + if (started.status === "already_editing") { + const discarded = discardChatQueuedMessageEdit(snapshot.composer.queue); + if (discarded.status !== "ended") return snapshot; + return { + ...snapshot, + composer: { + ...snapshot.composer, + input: discarded.restoreInput, + queue: discarded.queue + } + }; + } + if (started.status !== "started") return snapshot; + shouldFocus = true; + return { + ...snapshot, + composer: { + ...snapshot.composer, + input: started.input, + queue: started.queue + } + }; + }); + if (shouldFocus) requestAnimationFrame(() => textareaRef.current?.focus()); + }, + [runtimeStore] + ); + + // Helper function to process one streaming response. const processStreamingResponse = useCallback( async ( stream: AsyncIterable, runtimeKey: ChatRuntimeKey, runToken: number, optimisticMessageId: string, - discardOwnedItemsOnError: boolean + onResponseCreated: (responseId: string) => Promise ): Promise => { const messageTextBuffers = new Map>(); const reasoningTextBuffers = new Map>(); @@ -4109,12 +4704,15 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { if (eventType === "response.created") { unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId); const eventWithResponse = event as { response?: { id?: string } }; - if (eventWithResponse.response?.id) { - runtimeStore.setCurrentResponseId( - runtimeKey, - runToken, - eventWithResponse.response.id - ); + const responseId = eventWithResponse.response?.id; + if (responseId) { + if (runtimeStore.setCurrentResponseId(runtimeKey, runToken, responseId)) { + clearUnresolvedChatResponseMessage(runtimeStore, runToken, optimisticMessageId); + } + if (await onResponseCreated(responseId)) { + terminalState = "cancelled"; + break; + } } } else if (eventType === "response.output_item.added") { const addedEvent = event as ResponseOutputItemAddedEvent; @@ -4290,6 +4888,9 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { } } else if (eventType === "response.completed") { terminalState = "completed"; + // This is the authoritative terminal frame. Do not let a later + // transport-close error turn a durable completion into a retry. + break; } else if (isTerminalChatStreamErrorEvent(eventType)) { terminalState = "error"; console.error("Streaming error:", event); @@ -4323,11 +4924,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { // and account teardown clear run ownership before aborting, so their // resulting iterator errors remain stale-fenced here. deltaCoalescer.finish(); - updateRunMessages((messages) => - discardOwnedItemsOnError - ? removeOwnedChatStreamAttemptItems(messages, ownedItemIds) - : updateActiveItemStatuses(messages, "error", ownedItemIds) - ); + updateRunMessages((messages) => updateActiveItemStatuses(messages, "error", ownedItemIds)); throw error; } finally { // Natural EOF and thrown stream errors both commit the final partial @@ -4342,134 +4939,158 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { [logStreamEvent, runtimeStore] ); - // Every send captures its owning runtime key. Navigation only changes the - // projected runtime; it never changes where this request or its SSE events land. + // Every submit synchronously detaches its owning composer. During an active + // run that creates a staged chip; otherwise one outer run drains the FIFO as + // separate Responses turns without an idle run-token or AbortController gap. const handleSendMessage = useCallback( async (e?: React.FormEvent, overrideInput?: string, ownerRuntimeKey?: ChatRuntimeKey) => { e?.preventDefault(); let runtimeKey = runtimeStore.resolveKey(ownerRuntimeKey ?? activeRuntimeKeyRef.current); const startSnapshot = runtimeStore.get(runtimeKey); - if (!startSnapshot || !openai) return; - - const originalComposer = startSnapshot.composer; - const textToSend = overrideInput ?? originalComposer.input; - const trimmedInput = textToSend.trim(); - const originalImages = [...originalComposer.draftImages]; - const originalDocumentText = originalComposer.documentText; - const originalDocumentName = originalComposer.documentName; - const hasContent = - trimmedInput.length > 0 || originalImages.length > 0 || originalDocumentText.length > 0; - if (!hasContent || startSnapshot.isGenerating || originalComposer.isProcessingDocument) { + if (!startSnapshot) return; + const retainOverrideInput = () => { + if (overrideInput === undefined) return; + runtimeStore.update(runtimeKey, (snapshot) => ({ + ...snapshot, + composer: chatComposerWithInputOverride(snapshot.composer, overrideInput) + })); + }; + if (!openai || isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) { + retainOverrideInput(); + return; + } + const isRuntimeStopping = (runToken?: number) => + Array.from(stoppingRuntimeRegistry.getEntries()).some( + ([key, runTokens]) => + (runToken === undefined || runTokens.has(runToken)) && + runtimeStore.resolveKey(key) === runtimeKey + ); + if (isRuntimeStopping()) { + retainOverrideInput(); + return; + } + + const metadata: ChatQueuedMessageMetadata = { + queueId: uuidv4(), + messageId: uuidv4(), + model: model || DEFAULT_MODEL_ID, + webSearchEnabled: isWebSearchEnabled, + createdMs: Date.now() + }; + const composerAtSubmit = chatComposerWithInputOverride(startSnapshot.composer, overrideInput); + const preflight = planChatComposerSubmission({ + composer: composerAtSubmit, + hasActiveRun: startSnapshot.isGenerating, + metadata, + accountUsage: chatAccountQueueUsage(runtimeStore) + }); + const submissionError = (status: typeof preflight.status): string | null => { + if (status === "queue_full") return "You can queue up to 16 messages."; + if (status === "text_too_large") return "Queued messages must be 32 KiB or smaller."; + if (status === "too_many_images") { + return "You can attach up to 10 images to a queued message."; + } + if (status === "image_too_large") { + return "Each queued image must be 20 MiB or smaller."; + } + if (status === "document_too_large") { + return "Queued documents must be 10 MiB or smaller."; + } + if (status === "queue_payload_too_large") { + return "Queued attachments can use up to 256 MiB in total."; + } + if (status === "account_queue_full") { + return "You can retain up to 64 queued chat messages across your account."; + } + if (status === "account_payload_too_large") { + return "Chat drafts and queued attachments can use up to 256 MiB across your account."; + } + if (status === "processing") return "Wait for the document to finish processing."; + if (status === "missing_edit") return "That queued message is no longer available."; + return null; + }; + + if (startSnapshot.isGenerating) { + runtimeStore.update(runtimeKey, (snapshot) => { + const composer = chatComposerWithInputOverride(snapshot.composer, overrideInput); + const plan = planChatComposerSubmission({ + composer, + hasActiveRun: true, + metadata, + accountUsage: chatAccountQueueUsage(runtimeStore) + }); + if (plan.status === "queued" || plan.status === "updated") { + return { ...snapshot, composer: plan.composer, error: null }; + } + return { ...snapshot, error: submissionError(plan.status) ?? snapshot.error }; + }); + return; + } + + if (preflight.status !== "start") { + const message = submissionError(preflight.status); + if (message) setErrorForKey(runtimeKey, message); return; } - const requestModel = model || DEFAULT_MODEL_ID; - const requestWebSearchEnabled = isWebSearchEnabled; - const billingStatusAtSend = billingStatus; - const existingConversationId = - startSnapshot.conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeKey); - const isFollowUpConversation = - Boolean(existingConversationId) && startSnapshot.messages.length > 1; const run = runtimeStore.beginRun(runtimeKey, { groupId: startSnapshot.conversation?.project_id ?? - originalComposer.draftProjectId ?? + preflight.item.draftProjectId ?? selectedProjectId ?? null }); - const localMessageId = uuidv4(); - let conversationId = existingConversationId; - let composerRestored = false; - let adoptedExistingDestination = false; - let completedSuccessfully = false; - - const restoreOriginComposer = (message: string) => { - if (composerRestored) return true; - - let createdUrls: string[] = []; - let displacedUrls: string[] = []; - const restored = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => { - const adoptedDestinationRecovery = recoverFailedSendAfterDestinationAdoption( - adoptedExistingDestination, - snapshot.messages as Message[], - snapshot.composer, - localMessageId - ); - if (adoptedDestinationRecovery) { - return { - ...snapshot, - messages: adoptedDestinationRecovery.messages, - composer: adoptedDestinationRecovery.composer, - error: message - }; - } - - const restoredUrlPlan = planRestoredImageUrls( - originalImages, - snapshot.composer.imageUrls, - (file) => URL.createObjectURL(file) - ); - createdUrls = restoredUrlPlan.createdUrls; - displacedUrls = restoredUrlPlan.displacedUrls; - - return { - ...snapshot, - messages: (snapshot.messages as Message[]).filter((item) => item.id !== localMessageId), - error: message, - composer: { - ...snapshot.composer, - input: textToSend, - draftImages: originalImages, - imageUrls: restoredUrlPlan.imageUrls, - documentText: originalDocumentText, - documentName: originalDocumentName, - isProcessingDocument: false, - attachmentError: null, - imagePasteGeneration: snapshot.composer.imagePasteGeneration + 1, - documentUploadGeneration: snapshot.composer.documentUploadGeneration + 1 - } - }; - }); - - if (!restored) { - for (const url of createdUrls) URL.revokeObjectURL(url); - return false; - } - for (const url of displacedUrls) URL.revokeObjectURL(url); - composerRestored = true; - return true; + type ChatQueueTurn = { + item: ChatQueuedMessage; + recoverOnFailure: boolean; + previousLastSeenItemId: string | undefined; }; + let currentTurn: ChatQueueTurn | undefined; + const started = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => { + const composer = chatComposerWithInputOverride(snapshot.composer, overrideInput); + const plan = planChatComposerSubmission({ + composer, + hasActiveRun: false, + metadata, + accountUsage: chatAccountQueueUsage(runtimeStore) + }); + if (plan.status !== "start") return snapshot; + currentTurn = { + item: plan.item, + recoverOnFailure: plan.recoverOnFailure, + previousLastSeenItemId: snapshot.lastSeenItemId + }; + return { + ...snapshot, + composer: plan.composer, + messages: mergeMessagesById(snapshot.messages as Message[], [ + promotedChatUserMessage(plan.item) + ]), + lastSeenItemId: plan.item.messageId, + error: null + }; + }); + if (!started || !currentTurn) { + runtimeStore.finishRun(runtimeKey, run.token); + return; + } + registerChatOptimisticMessage(runtimeStore, run.token, currentTurn.item.messageId); + if ( + !isCompactLayout && + startSnapshot.messages.length === 0 && + isRuntimeSelected(runtimeKey) + ) { + // The first optimistic row swaps the centered composer for the bottom + // composer. Restore focus once after that remount so follow-ups can be + // queued immediately without focusing on every streamed item. + requestAnimationFrame(() => textareaRef.current?.focus()); + } - const createResponseStream = async ( - targetConversationId: string, - discardOwnedItemsOnError: boolean - ) => { - const stream = await openai.responses.create( - { - conversation: targetConversationId, - model: requestModel, - input: [{ role: "user", content: messageContent }], - metadata: { internal_message_id: localMessageId }, - stream: true, - store: true, - ...(requestWebSearchEnabled && { tools: [{ type: "web_search" }] }) - }, - { signal: run.signal } - ); - - if (!runtimeStore.setAssistantStreaming(runtimeKey, run.token, true)) return null; - try { - return await processStreamingResponse( - stream, - runtimeKey, - run.token, - localMessageId, - discardOwnedItemsOnError - ); - } finally { - runtimeStore.setAssistantStreaming(runtimeKey, run.token, false); - } - }; + let conversationId = + startSnapshot.conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeKey); + let completedAnyTurn = false; + let stopOwnsSettlement = false; + let detachedResponseOwnsSettlement = false; const scheduleBillingRefresh = () => { const timeout = setTimeout(() => { @@ -4479,305 +5100,520 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { billingRefreshTimeoutsRef.current.add(timeout); }; - const messageContent: (InputTextContent | InputImageContent)[] = []; - let finalText = trimmedInput; - if (originalDocumentText) { - finalText = originalDocumentText + (trimmedInput ? `\n\n${trimmedInput}` : ""); - } - if (finalText) { - messageContent.push({ - type: "input_text", - text: finalText - }); - } + const sendTurn = async (turn: ChatQueueTurn): Promise => { + const { item, recoverOnFailure, previousLastSeenItemId } = turn; + const localMessageId = item.messageId; + let ownsObjectUrls = true; + let responseRequestStarted = false; + let conversationCreateInFlight = false; + let turnRestored = false; + let preserveUnresolvedResponseOwnership = false; + + const releaseObjectUrls = () => { + if (!ownsObjectUrls) return; + ownsObjectUrls = false; + const snapshot = runtimeStore.get(runtimeKey); + const retainedObjectUrls = new Set( + snapshot + ? [ + ...chatComposerObjectUrls(snapshot.composer), + ...chatTranscriptObjectUrls(snapshot.messages as Message[]) + ] + : [] + ); + revokeQueuedChatMessageObjectUrls(item, retainedObjectUrls); + }; - try { - for (const file of originalImages) { - try { - const dataUrl = await fileToDataURL(file); - messageContent.push({ - type: "input_image", - image_url: dataUrl, - detail: "auto", - file_id: null - }); - } catch (error) { - console.error("Failed to convert image:", error); + const restoreTurn = (message: string) => { + if (turnRestored) return true; + let transferred = false; + const restored = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => { + const recovery = recoverDetachedChatComposerDraft(snapshot.composer, item); + let composer = recovery.composer; + if (!recoverOnFailure && recovery.status === "restored") { + composer = { + ...snapshot.composer, + queue: { + ...snapshot.composer.queue, + items: [item, ...snapshot.composer.queue.items] + } + }; + } + transferred = true; + return { + ...snapshot, + messages: (snapshot.messages as Message[]).filter( + (messageItem) => messageItem.id !== localMessageId + ), + lastSeenItemId: + snapshot.lastSeenItemId === localMessageId + ? previousLastSeenItemId + : snapshot.lastSeenItemId, + composer, + error: message + }; + }); + if (restored && transferred) { + ownsObjectUrls = false; + turnRestored = true; } - } - if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return; - - const userMessage = { - id: localMessageId, - type: "message", - role: "user", - content: messageContent, - status: "completed" - } as unknown as Message; - - const stagedImageUrls = new Set(); - const staged = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => { - for (const url of snapshot.composer.imageUrls.values()) stagedImageUrls.add(url); - return { + return restored && transferred; + }; + + const markTurnIncomplete = (message: string) => { + runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ ...snapshot, - messages: mergeMessagesById(snapshot.messages as Message[], [userMessage]), - lastSeenItemId: localMessageId, - composer: { - ...snapshot.composer, - input: "", - draftImages: [], - imageUrls: new Map(), - documentText: "", - documentName: "", - isProcessingDocument: false, - attachmentError: null, - imagePasteGeneration: snapshot.composer.imagePasteGeneration + 1, - documentUploadGeneration: snapshot.composer.documentUploadGeneration + 1 - } - }; + messages: markOptimisticMessageIncomplete( + snapshot.messages as Message[], + localMessageId + ), + lastSeenItemId: chatCursorAfterSendFailure({ + currentCursor: snapshot.lastSeenItemId, + optimisticMessageId: localMessageId, + previousCursor: previousLastSeenItemId, + responseCreated: Boolean(snapshot.currentResponseId) + }), + error: message + })); + }; + + const unregisterCurrentTurn = registerChatCurrentTurn(runtimeStore, run.token, { + responseRequestStarted: () => responseRequestStarted, + serverRequestInFlight: () => conversationCreateInFlight, + restoreBeforeRequest: restoreTurn, + retainedPayload: item, + retainsPayload: () => !turnRestored, + countsTowardQueueLimit: true }); - if (!staged) return; - registerChatOptimisticMessage(runtimeStore, run.token, localMessageId); - for (const url of stagedImageUrls) URL.revokeObjectURL(url); - - if (!conversationId) { - const createParams: Parameters[0] & { - project_id?: string; - } = { - metadata: {}, - ...(originalComposer.draftProjectId && { - project_id: originalComposer.draftProjectId - }) - }; - const newConv = await openai.conversations.create(createParams, { - signal: run.signal - }); - conversationId = newConv.id; - const sourceWasSelected = isRuntimeSelected(runtimeKey); - const destinationKey = createConversationChatKey(conversationId); - const destinationSnapshot = runtimeStore.get(destinationKey); - const rawRecordingOwnerKey = recordingOwnerKeyRef.current; - const canonicalRecordingOwnerKey = rawRecordingOwnerKey - ? runtimeStore.resolveKey(rawRecordingOwnerKey) - : null; - if (!canAdoptRecordingDestination(destinationKey, canonicalRecordingOwnerKey)) { - // Let pending microphone, recording, or transcription work finish on - // the idle destination. Adoption would make its eventual send fail. - restoreOriginComposer( - "This conversation is still processing a voice message. Your message was restored in its original draft." - ); - return; + + const messageContent = queuedChatMessageContent(item, () => undefined); + const createResponseStream = async () => { + if ( + !runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ + ...snapshot, + assistantStreaming: false + })) + ) { + return null; } - if (!canAdoptAttachmentDestination(destinationSnapshot)) { - // Let the destination's extraction callback finish on its original - // idle runtime. Adopting it into this run would fence that callback - // and permanently strand isProcessingDocument=true. - restoreOriginComposer( - "This conversation is still processing an attachment. Your message was restored in its original draft." + registerChatOptimisticMessage(runtimeStore, run.token, localMessageId); + registerUnresolvedChatResponseMessage(runtimeStore, run.token, localMessageId); + responseRequestStarted = true; + const stream = await openai.responses.create( + { + conversation: conversationId!, + model: item.model, + input: [{ role: "user", content: messageContent }], + metadata: { internal_message_id: localMessageId }, + stream: true, + store: true, + ...(item.webSearchEnabled && { tools: [{ type: "web_search" }] }) + }, + { signal: run.signal } + ); + + if (!runtimeStore.setAssistantStreaming(runtimeKey, run.token, true)) return null; + try { + return await processStreamingResponse( + stream, + runtimeKey, + run.token, + localMessageId, + async (responseId) => { + if (!isRuntimeStopping(run.token)) return false; + return cancelKnownChatResponse(runtimeKey, run.token, responseId, localMessageId); + } ); - return; + } finally { + runtimeStore.setAssistantStreaming(runtimeKey, run.token, false); } - const migration = runtimeStore.rekeyRunAdoptingIdleDestination( - runtimeKey, - destinationKey, - run.token, - (source, destination) => ({ - ...source, - conversation: destination.conversation ?? source.conversation, - messages: mergeLoadedMessagesWithRuntime( - destination.messages as Message[], - source.messages - ), - composer: destination.composer, - error: source.error ?? destination.error, - lastSeenItemId: source.lastSeenItemId ?? destination.lastSeenItemId, - historyLoaded: source.historyLoaded || destination.historyLoaded - }) - ); + }; - if (migration.status === "source_stale") { - // Creation may already be visible to another tab or device. Without - // atomic server proof that C is empty and owned by this attempt, - // prefer a harmless empty orphan over deleting real chat history. - return; + try { + const dataUrls = new Map(); + for (const file of item.draftImages) { + try { + dataUrls.set(file, await fileToDataURL(file)); + } catch (error) { + console.error("Failed to convert image:", error); + const restored = restoreTurn( + "An image could not be prepared. Your message and images were restored; please try again." + ); + if (!restored && runtimeStore.get(runtimeKey)) { + const durableContent = queuedChatMessageContent(item, (image) => + dataUrls.get(image) + ); + runtimeStore.update(runtimeKey, (snapshot) => ({ + ...snapshot, + messages: markOptimisticMessageIncomplete( + updateMessageById(snapshot.messages as Message[], localMessageId, (message) => + message.type === "message" + ? ({ ...message, content: durableContent } as unknown as Message) + : message + ), + localMessageId + ) + })); + } + return false; + } } + messageContent.push( + ...queuedChatMessageContent({ ...item, text: "", documentText: "" }, (file) => + dataUrls.get(file) + ) + ); - if (migration.status === "destination_active") { - // Never replace or delete a destination that already owns a run. - // Browser history retains this source draft, so restoring here makes - // the original prompt and attachments recoverable with Back. - restoreOriginComposer( - "This conversation became active before your message was sent. Your message was restored in its original draft." + const updateMaterializedMessage = (messages: readonly Message[]) => + updateMessageById(messages as Message[], localMessageId, (message) => + message.type === "message" + ? ({ ...message, content: messageContent } as unknown as Message) + : message ); - return; + if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) { + if (runtimeStore.get(runtimeKey)) { + runtimeStore.update(runtimeKey, (snapshot) => ({ + ...snapshot, + messages: updateMaterializedMessage(snapshot.messages as Message[]) + })); + } + return false; + } + if ( + !runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ + ...snapshot, + messages: updateMaterializedMessage(snapshot.messages as Message[]) + })) + ) { + return false; + } + if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) { + restoreTurn("Sending paused because this conversation is being deleted."); + return false; } - runtimeKey = migration.key; - adoptedExistingDestination = migration.adoptedExistingDestination; - runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ - ...snapshot, - conversation: newConv as Conversation, - composer: { ...snapshot.composer, draftProjectId: null } - })); + if (!conversationId) { + const createParams: Parameters[0] & { + project_id?: string; + } = { + metadata: {}, + ...(item.draftProjectId && { project_id: item.draftProjectId }) + }; + conversationCreateInFlight = true; + let newConv: Awaited>; + try { + newConv = await openai.conversations.create(createParams, { + signal: run.signal + }); + } finally { + conversationCreateInFlight = false; + } + conversationId = newConv.id; + const sourceWasSelected = isRuntimeSelected(runtimeKey); + const destinationKey = createConversationChatKey(conversationId); + const destinationSnapshot = runtimeStore.get(destinationKey); + const rawRecordingOwnerKey = recordingOwnerKeyRef.current; + const canonicalRecordingOwnerKey = rawRecordingOwnerKey + ? runtimeStore.resolveKey(rawRecordingOwnerKey) + : null; + if (!canAdoptRecordingDestination(destinationKey, canonicalRecordingOwnerKey)) { + restoreTurn( + "This conversation is still processing a voice message. Your message was restored in its original draft." + ); + return false; + } + if (!canAdoptAttachmentDestination(destinationSnapshot)) { + restoreTurn( + "This conversation is still processing an attachment. Your message was restored in its original draft." + ); + return false; + } - const keepSelection = shouldProjectMigratedConversation( - runtimeStore.isChatVisible(runtimeKey), - sourceWasSelected, - migration.destinationWasSelected - ); - if (keepSelection) { - activeRuntimeKeyRef.current = runtimeKey; - setActiveRuntimeKey(runtimeKey); - setChatId(conversationId); - canonicalizeConversationHistoryEntry(conversationId); - } - window.dispatchEvent(new Event("conversationcreated")); - } + let displacedObjectUrls: string[] = []; + const migration = runtimeStore.rekeyRunAdoptingIdleDestination( + runtimeKey, + destinationKey, + run.token, + (source, destination) => { + const mergedComposer = mergeChatComposerDraftsForRekey( + source.composer, + destination.composer, + destinationKey + ); + displacedObjectUrls = mergedComposer.displacedObjectUrls; + return { + ...source, + conversation: destination.conversation ?? source.conversation, + messages: mergeLoadedMessagesWithRuntime( + destination.messages as Message[], + source.messages + ), + composer: mergedComposer.composer, + error: source.error ?? destination.error, + lastSeenItemId: source.lastSeenItemId ?? destination.lastSeenItemId, + historyLoaded: source.historyLoaded || destination.historyLoaded + }; + } + ); - const terminalState = await createResponseStream(conversationId, isFollowUpConversation); - completedSuccessfully = terminalState === "completed"; - scheduleBillingRefresh(); - } catch (error) { - console.error("Failed to send message:", error); - let errorMessage = error instanceof Error ? error.message : "Something went wrong"; - const causeMessage = (error as Error & { cause?: { message?: string } })?.cause?.message; - if (causeMessage && causeMessage.includes("Request failed with status")) { - errorMessage = causeMessage; - } + if (migration.status === "source_stale") return false; + if (migration.status === "destination_active") { + restoreTurn( + "This conversation became active before your message was sent. Your message was restored in its original draft." + ); + return false; + } + for (const url of displacedObjectUrls) URL.revokeObjectURL(url); - if (isImageDescriptionUnavailableError(error)) { - restoreOriginComposer( - "Image description is temporarily unavailable. Your message and images were restored; please try again." - ); - return; - } + runtimeKey = migration.key; + runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ + ...snapshot, + conversation: newConv as Conversation, + composer: { ...snapshot.composer, draftProjectId: null } + })); - const parseStatusError = (status: number) => { - if (!errorMessage.includes(`Request failed with status ${status}:`)) return null; - try { - const jsonMatch = errorMessage.match( - new RegExp(`Request failed with status ${status}:\\s*({.*})`) + const keepSelection = shouldProjectMigratedConversation( + runtimeStore.isChatVisible(runtimeKey), + sourceWasSelected, + migration.destinationWasSelected ); - return jsonMatch?.[1] - ? (JSON.parse(jsonMatch[1]) as { status: number; message: string }) - : null; - } catch (parseError) { - console.error(`Failed to parse ${status} error:`, parseError); - return null; + if (keepSelection) { + activeRuntimeKeyRef.current = runtimeKey; + setActiveRuntimeKey(runtimeKey); + setChatId(conversationId); + canonicalizeConversationHistoryEntry(conversationId); + } + window.dispatchEvent(new Event("conversationcreated")); } - }; - const status413Error = parseStatusError(413); - if (status413Error && status413Error.message === "Message exceeds context limit") { - restoreOriginComposer("Your message exceeds the context limit for this model."); - if (isRuntimeSelected(runtimeKey)) setContextLimitDialogOpen(true); - return; - } + if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) { + restoreTurn("Sending paused because this conversation is being deleted."); + return false; + } - const status403Error = parseStatusError(403); - if (status403Error) { - let displayError: string; - if (status403Error.message === "Free tier token limit exceeded") { - displayError = - "This conversation is too long for the free tier. Upgrade to Pro for longer conversations."; - if (isRuntimeSelected(runtimeKey)) { - setUpgradeFeature("tokens"); - setUpgradeDialogOpen(true); + const terminalState = await createResponseStream(); + scheduleBillingRefresh(); + return terminalState === "completed"; + } catch (error) { + console.error("Failed to send message:", error); + let errorMessage = error instanceof Error ? error.message : "Something went wrong"; + const causeMessage = (error as Error & { cause?: { message?: string } })?.cause?.message; + if (causeMessage?.includes("Request failed with status")) errorMessage = causeMessage; + + const retainAmbiguousResponseOwnership = (message: string) => { + markTurnIncomplete(message); + preserveUnresolvedResponseOwnership = !runtimeStore.get(runtimeKey)?.currentResponseId; + detachedResponseOwnsSettlement = true; + }; + + if (isChatAccountCredentialMismatchError(error)) { + if (!responseRequestStarted || isChatRequestDefinitelyNotDispatchedError(error)) { + restoreTurn( + "Sending paused because the authenticated account changed. Your message was restored." + ); + } else { + retainAmbiguousResponseOwnership( + "The authenticated account changed after sending began. The response may still be running; queued messages were kept." + ); } - } else if (status403Error.message === "Usage limit reached") { - const isFreeTier = - !billingStatusAtSend?.product_name || - billingStatusAtSend.product_name.toLowerCase() === "free"; + return false; + } + + if (isImageDescriptionUnavailableError(error)) { + restoreTurn( + "Image description is temporarily unavailable. Your message and images were restored; please try again." + ); + return false; + } - if (isFreeTier) { + const parseStatusError = (status: number) => { + if (!errorMessage.includes(`Request failed with status ${status}:`)) return null; + try { + const jsonMatch = errorMessage.match( + new RegExp(`Request failed with status ${status}:\\s*({.*})`) + ); + return jsonMatch?.[1] + ? (JSON.parse(jsonMatch[1]) as { status: number; message: string }) + : null; + } catch (parseError) { + console.error(`Failed to parse ${status} error:`, parseError); + return null; + } + }; + + const status413Error = parseStatusError(413); + if (status413Error?.message === "Message exceeds context limit") { + restoreTurn("Your message exceeds the context limit for this model."); + if (isRuntimeSelected(runtimeKey)) setContextLimitDialogOpen(true); + return false; + } + + const status403Error = parseStatusError(403); + if (status403Error) { + let displayError: string; + if (status403Error.message === "Free tier token limit exceeded") { displayError = - "You've reached your daily usage limit. Upgrade to Pro for more chats."; + "This conversation is too long for the free tier. Upgrade to Pro for longer conversations."; + if (isRuntimeSelected(runtimeKey)) { + setUpgradeFeature("tokens"); + setUpgradeDialogOpen(true); + } + } else if (status403Error.message === "Usage limit reached") { + const isFreeTier = + !billingStatus?.product_name || billingStatus.product_name.toLowerCase() === "free"; + if (isFreeTier) { + displayError = + "You've reached your daily usage limit. Upgrade to Pro for more chats."; + } else { + const isPro = + billingStatus.product_name?.toLowerCase().includes("pro") && + !billingStatus.product_name?.toLowerCase().includes("max"); + displayError = isPro + ? "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage." + : "You've reached your monthly usage limit. Please wait for the next billing cycle."; + } + if (isRuntimeSelected(runtimeKey)) { + setUpgradeFeature("usage"); + setUpgradeDialogOpen(true); + } } else { - const isPro = - billingStatusAtSend.product_name?.toLowerCase().includes("pro") && - !billingStatusAtSend.product_name?.toLowerCase().includes("max"); - displayError = isPro - ? "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage." - : "You've reached your monthly usage limit. Please wait for the next billing cycle."; + displayError = + status403Error.message || "Access denied. Please check your subscription."; } - if (isRuntimeSelected(runtimeKey)) { - setUpgradeFeature("usage"); - setUpgradeDialogOpen(true); + restoreTurn(displayError); + return false; + } + + if ( + isChatRequestDefinitelyNotDispatchedError(error) || + isChatResponseDefinitelyRejectedError(error) + ) { + restoreTurn(`${errorMessage}. Please try again.`); + return false; + } + + if (error instanceof Error && error.name !== "AbortError") { + if (!responseRequestStarted) { + restoreTurn(`${errorMessage}. Please try again.`); + } else { + // Once an ambiguous streaming POST has started, replaying it can + // create a duplicate provider turn. Keep later FIFO items staged + // and retain known server ownership until polling or Stop reaches + // a terminal response state. + retainAmbiguousResponseOwnership( + `${errorMessage}. The response may still be running; queued messages were kept.` + ); } - } else { - displayError = - status403Error.message || "Access denied. Please check your subscription."; } - restoreOriginComposer(displayError); - } else if (error instanceof Error && error.name !== "AbortError") { - if (isFollowUpConversation && conversationId) { - try { - console.log("Waiting 1s before retry..."); - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return; - - console.log("Retrying request once..."); - const terminalState = await createResponseStream(conversationId, false); - completedSuccessfully = terminalState === "completed"; - scheduleBillingRefresh(); - console.log("Retry completed successfully"); - return; - } catch (retryError) { - console.error("Retry failed:", retryError); - if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return; - - try { - const finalCheckResponse = await openai.conversations.items.list(conversationId, { - limit: 5, - order: "desc" - }); - const foundMessage = finalCheckResponse.data.find( - (item) => item.id === localMessageId - ); + return false; + } finally { + unregisterCurrentTurn(); + unregisterChatOptimisticMessage(runtimeStore, run.token, localMessageId); + if (!preserveUnresolvedResponseOwnership) { + clearUnresolvedChatResponseMessage(runtimeStore, run.token, localMessageId); + } + releaseObjectUrls(); + } + }; - if (!foundMessage) { - console.log("Message not found after retry - restoring input"); - restoreOriginComposer("Failed to send message. Please try again."); - } else { - console.log("Message found after retry failure - it actually went through"); - } - } catch (finalCheckError) { - console.error("Final check failed:", finalCheckError); - restoreOriginComposer("Failed to send message. Please try again."); + try { + while (currentTurn && runtimeStore.isRunCurrent(runtimeKey, run.token)) { + const completed = await sendTurn(currentTurn); + if (!completed || !runtimeStore.isRunCurrent(runtimeKey, run.token)) { + if (runtimeStore.isRunCurrent(runtimeKey, run.token) && isRuntimeStopping(run.token)) { + const responseId = runtimeStore.get(runtimeKey)?.currentResponseId; + stopOwnsSettlement = Boolean(responseId); + if (!responseId) { + // A failed POST before response.created can still be accepted + // server-side. Keep the run and Stop intent fenced until item + // polling recovers its response UUID; never overlap the FIFO. + setErrorForKey( + runtimeKey, + "Maple could not confirm that the response stopped. Queued messages were kept." + ); } } - } else { - const optimisticMessageId = getRegisteredChatOptimisticMessage(runtimeStore, run.token); - runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({ + break; + } + completedAnyTurn = true; + if (isChatRunQueueHaltRequested(runtimeStore, run.token)) { + if (!runtimeStore.get(runtimeKey)?.currentResponseId) { + stoppingRuntimeRegistry.delete(runtimeKey, run.token); + } + break; + } + if (isRuntimeStopping(run.token)) break; + if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) break; + + let nextTurn: ChatQueueTurn | undefined; + const advanced = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => { + const next = takeNextChatQueuedMessage(snapshot.composer.queue); + if (next.status !== "taken") { + return { + ...snapshot, + currentResponseId: undefined, + assistantStreaming: false + }; + } + nextTurn = { + item: next.item, + recoverOnFailure: false, + previousLastSeenItemId: snapshot.lastSeenItemId + }; + return { ...snapshot, - messages: markOptimisticMessageIncomplete( - snapshot.messages as Message[], - optimisticMessageId - ), - error: `${errorMessage}. Please try again.` - })); + composer: { ...snapshot.composer, queue: next.queue }, + messages: mergeMessagesById(snapshot.messages as Message[], [ + promotedChatUserMessage(next.item) + ]), + lastSeenItemId: next.item.messageId, + currentResponseId: undefined, + assistantStreaming: false, + error: null + }; + }); + if (!advanced) break; + if (!nextTurn) { + break; } + currentTurn = nextTurn; + registerChatOptimisticMessage(runtimeStore, run.token, currentTurn.item.messageId); } } finally { - unregisterChatOptimisticMessage(runtimeStore, run.token, localMessageId); - if (completedSuccessfully) { - runtimeStore.completeRun(runtimeKey, run.token); - } else { - runtimeStore.finishRun(runtimeKey, run.token); + if ( + runtimeStore.isRunCurrent(runtimeKey, run.token) && + !stopOwnsSettlement && + !detachedResponseOwnsSettlement + ) { + if (completedAnyTurn) runtimeStore.completeRun(runtimeKey, run.token); + else runtimeStore.finishRun(runtimeKey, run.token); + } + if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) { + clearUnresolvedChatResponseMessage(runtimeStore, run.token); + } + if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) { + stoppingRuntimeRegistry.delete(runtimeKey, run.token); + } + if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) { + clearChatRunQueueHalt(runtimeStore, run.token); } } }, [ billingRefreshTimeoutsRef, billingStatus, + cancelKnownChatResponse, isRuntimeSelected, + isCompactLayout, isWebSearchEnabled, model, openai, processStreamingResponse, queryClient, runtimeStore, - selectedProjectId + selectedProjectId, + setErrorForKey, + stoppingRuntimeRegistry ] ); @@ -4785,6 +5621,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { // On desktop: Enter submits, Shift+Enter for new line // On mobile: Enter for new line, no keyboard shortcut to submit (use button) if (e.nativeEvent.isComposing) return; + if (e.key === "Escape" && queueEdit) { + e.preventDefault(); + discardQueueEdit(); + return; + } if ((e.shiftKey || isCompactLayout) && continueChatComposerList(e, setInput)) { return; } @@ -4983,7 +5824,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
- {(draftImages.length > 0 || documentName) && ( + {!queueEdit && (draftImages.length > 0 || documentName) && (
{draftImages.length > 0 && (
@@ -4997,7 +5838,6 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { - {isGenerating ? ( + {queueEdit ? ( + + ) : null} + {showsStop ? ( - ) : ( - - )} + ) : null} +
@@ -5234,7 +6083,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
- {(draftImages.length > 0 || documentName) && ( + {!queueEdit && (draftImages.length > 0 || documentName) && (
{draftImages.length > 0 && (
@@ -5248,7 +6097,6 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {