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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 44 additions & 16 deletions apps/console/src/components/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ChatDropProvider } from '@/components/chat/ChatDropOverlay';
import { useChatPageRuntime } from '@/components/chat/runtime';
import type { ChatArtifactPayload, ChatCatalog, ChatThreadRecord } from '@/lib/chat/project-types';
import {
ChatWireError,
createChatThread,
fetchChatCatalog,
fetchChatThread,
Expand All @@ -47,6 +48,30 @@ const emptySubscribe = () => () => {};
const MESSAGE_PERSIST_DEBOUNCE_MS = 500;
const EMPTY_CAPABILITIES: readonly CapabilityItem[] = [];

/** A failed chat request, kept with the evidence needed to describe it. */
type ChatFailure = { code: string; door?: string; status?: number };

/**
* Classify a rejected chat request.
*
* `/api/chat/*` are the console's own routes. Before this, every rejection
* here was relabelled `console_data_api_unreachable`, so a 500 from
* /api/chat/projects told the reader the data API was down while it was
* answering normally. Report the code the route actually named, and when it
* named none, say the chat wire failed, because that is what happened.
*/
function chatFailure(error: unknown, door: string): ChatFailure {
if (error instanceof ChatWireError) {
return {
code: error.wireCode ?? 'console_chat_wire_failed',
door: error.door,
status: error.status ?? undefined,
};
}
// fetch itself rejected, so there is no status: the request never landed.
return { code: 'console_chat_wire_failed', door };
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish response decoding failures from network failures

This fallback handles every non-ChatWireError, not only a rejected fetch. If /api/chat/projects or a thread route answers successfully but its JSON is malformed or truncated, response.json() throws a SyntaxError and this branch records no status; degradationFor then tells the user that the route did not answer and attributes it to DNS, the network, or a blocked origin even though the route did answer. Wrap successful-response decoding failures with the response status, or otherwise classify them separately from transport rejection.

Useful? React with 👍 / 👎.

}

function connectionFor(status: number | null, error?: string | null): ConnectionState {
if (status === 401 || error === 'principal_resolution=unauthenticated') return 'unauthenticated';
if (
Expand Down Expand Up @@ -235,7 +260,7 @@ export function ChatPage({
const connection = useShellStore((state) => state.connection);
const [catalog, setCatalog] = useState<ChatCatalog | null>(null);
const [thread, setThread] = useState<ChatThreadRecord | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<ChatFailure | null>(null);
const [railCollapsed, setRailCollapsed] = useState(false);
const [wide, setWide] = useState(true);
const [includeOverrides, setIncludeOverrides] = useState<Map<string, boolean>>(() => new Map());
Expand Down Expand Up @@ -292,7 +317,7 @@ export function ChatPage({
})
.catch((error: unknown) => {
if (active) {
setLoadError(error instanceof Error ? error.message : 'catalog_unreachable');
setLoadError(chatFailure(error, '/api/chat/projects'));
}
});
return () => {
Expand Down Expand Up @@ -339,7 +364,7 @@ export function ChatPage({
router.replace(`/chat/${encodeURIComponent(created.id)}`);
} catch (error) {
if (active) {
setLoadError(error instanceof Error ? error.message : 'thread_unreachable');
setLoadError(chatFailure(error, '/api/chat/threads'));
}
}
Comment on lines 365 to 369
};
Expand Down Expand Up @@ -417,20 +442,23 @@ export function ChatPage({
}
: undefined;

// Only the disconnected branch may carry that evidence. `connection` is
// derived from onTransport, so the last transport outcome is genuinely its
// outcome. `loadError` is not: it comes from the chat catalog and thread
// fetches, which are different requests. A healthy /api/objects/views probe
// followed by a 502 from /api/chat/projects would otherwise render a banner
// claiming the data API answered 200. Evidence about the wrong request is
// worse than no evidence, which is the failure this whole change exists to
// stop.
// Each branch reports its own request, and only its own.
//
// `loadError` comes from /api/chat/*, the console's own routes. It used to be
// relabelled `console_data_api_unreachable`, so a 500 from
// /api/chat/projects announced that the data API was down while the data API
// was answering normally. It now carries the code the route named, plus the
// door and status of the request that actually failed.
//
// `connection` is derived from onTransport, so the last transport outcome is
// genuinely its outcome and may carry `transportOrigin`. Attaching that
// origin to a chat failure would describe the wrong request, which is worse
// than describing none.
const degradation = loadError
? degradationFor(
loadError === 'workspace_object_scope_unenforced'
? 'workspace_object_scope_unenforced'
: 'console_data_api_unreachable',
)
? degradationFor(loadError.code, {
door: loadError.door,
status: loadError.status,
})
: connection === 'disconnected'
? degradationFor('console_data_api_unreachable', transportOrigin)
: null;
Expand Down
57 changes: 48 additions & 9 deletions apps/console/src/lib/chat/catalog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,56 @@ import type {
ChatThreadRecord,
} from '@/lib/chat/project-types';

async function readJson<T>(response: Response): Promise<T> {
/**
* A chat-route failure that keeps its evidence instead of flattening it into a
* string.
*
* These routes are the console's own (`/api/chat/*`). They are NOT the data
* API. Throwing a bare Error meant the caller had nothing but a message to go
* on, so ChatPage labelled every one of them "The data API is unreachable." and
* pointed the reader at a service that was answering fine.
*/
export class ChatWireError extends Error {
/** The console route dialed, e.g. '/api/chat/projects'. */
readonly door: string;
/** HTTP status, or null when the request never landed at all. */
readonly status: number | null;
/** The wire code the route named, when it named one. */
readonly wireCode: string | null;

constructor(options: {
message: string;
door: string;
status: number | null;
wireCode?: string | null;
}) {
super(options.message);
this.name = 'ChatWireError';
this.door = options.door;
this.status = options.status;
this.wireCode = options.wireCode ?? null;
}
}

async function readJson<T>(response: Response, door: string): Promise<T> {
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { message?: string; error?: string };
throw new Error(body.message ?? body.error ?? `chat catalog failed: ${response.status}`);
const body = (await response.json().catch(() => ({}))) as {
message?: string;
error?: string;
};
throw new ChatWireError({
message: body.message ?? body.error ?? `chat request failed: ${response.status}`,
door,
status: response.status,
wireCode: body.error ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve known inner wire codes

When the object seam returns a known code without a message, responseError turns that code into the thrown error message, and /api/chat/projects wraps it as { error: 'project_catalog_failed', message: '<inner code>' }. Selecting only body.error here therefore discards codes such as workspace_object_scope_unenforced; ChatPage passes the unmapped wrapper to degradationFor and shows the generic unavailable sentence instead of the existing scope-specific guidance. The previous path deliberately recognized that inner code, so preserve recognized message codes or return the underlying code as a separate structured field.

Useful? React with 👍 / 👎.

});
}
return response.json() as Promise<T>;
}

export async function fetchChatCatalog(): Promise<ChatCatalog> {
const response = await fetch('/api/chat/projects', { cache: 'no-store' });
return readJson<ChatCatalog>(response);
return readJson<ChatCatalog>(response, '/api/chat/projects');
}

export async function saveChatProject(
Expand All @@ -28,7 +67,7 @@ export async function saveChatProject(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(project),
});
return readJson<ChatProject>(response);
return readJson<ChatProject>(response, '/api/chat/projects');
}

export async function selectChatProject(projectId: string): Promise<ChatCatalog> {
Expand All @@ -37,7 +76,7 @@ export async function selectChatProject(projectId: string): Promise<ChatCatalog>
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activeProjectId: projectId }),
});
return readJson<ChatCatalog>(response);
return readJson<ChatCatalog>(response, '/api/chat/projects');
}

export async function createChatThread(input: {
Expand All @@ -50,14 +89,14 @@ export async function createChatThread(input: {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return readJson<ChatThreadRecord>(response);
return readJson<ChatThreadRecord>(response, '/api/chat/threads');
}

export async function fetchChatThread(threadId: string): Promise<ChatThreadRecord> {
const response = await fetch(`/api/chat/threads/${encodeURIComponent(threadId)}`, {
cache: 'no-store',
});
return readJson<ChatThreadRecord>(response);
return readJson<ChatThreadRecord>(response, `/api/chat/threads/${encodeURIComponent(threadId)}`);
}

export async function persistChatThread(
Expand All @@ -69,7 +108,7 @@ export async function persistChatThread(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return readJson<ChatThreadRecord>(response);
return readJson<ChatThreadRecord>(response, `/api/chat/threads/${encodeURIComponent(threadId)}`);
}

export async function persistChatMessages(
Expand Down
21 changes: 21 additions & 0 deletions apps/console/src/lib/degradation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ describe('degradationFor origin evidence', () => {
expect(result.detail).toContain('503');
});

// The console's /api/chat/* routes are not the data API. ChatPage used to
// relabel every chat rejection as console_data_api_unreachable, so a 500
// from /api/chat/projects reported an outage on a service that was
// answering normally. A chat failure must describe the chat wire.
it('does not blame the data API for a chat-route failure', () => {
const chat = degradationFor('console_chat_wire_failed', {
door: '/api/chat/projects',
status: 500,
});
expect(chat.cause.toLowerCase()).not.toContain('data api');
expect(chat.cause.toLowerCase()).toContain('chat');
expect(chat.detail).toContain('/api/chat/projects');
expect(chat.detail).toContain('500');
});

it('names the chat wire when the caller supplies no door', () => {
const chat = degradationFor('console_chat_wire_failed', { status: 502 });
expect(chat.detail).toMatch(/chat wire/i);
expect(chat.detail).toContain('502');
});

it('still keeps the wire code out of what the user sees', () => {
const result = degradationFor('console_data_api_unreachable', {
door: '/api/objects/views',
Expand Down
1 change: 1 addition & 0 deletions apps/console/src/lib/degradation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const WIRE_MAP: Record<string, DegradationTemplate> = {
level: 'unavailable',
cause: 'The chat wire could not complete this turn.',
actionLabel: 'Retry',
door: 'The chat wire',
},
web_search_unavailable: {
level: 'reduced',
Expand Down
Loading