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
16 changes: 13 additions & 3 deletions apps/console/src/components/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ 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 };
type ChatFailure = { code: string; door?: string; status?: number; reason?: string };

/**
* Classify a rejected chat request.
Expand All @@ -62,14 +62,23 @@ type ChatFailure = { code: string; door?: string; status?: number };
*/
function chatFailure(error: unknown, door: string): ChatFailure {
if (error instanceof ChatWireError) {
const code = error.wireCode ?? 'console_chat_wire_failed';
return {
code: error.wireCode ?? 'console_chat_wire_failed',
code,
door: error.door,
status: error.status ?? undefined,
// These routes wrap an inner failure and put its reason in `message`.
// That reason is the only part naming what actually broke, so keep it
// unless it just repeats the code the sentence already covers.
reason: error.message && error.message !== code ? error.message : undefined,
};
}
// fetch itself rejected, so there is no status: the request never landed.
return { code: 'console_chat_wire_failed', door };
return {
code: 'console_chat_wire_failed',
door,
reason: error instanceof Error ? error.message : undefined,
};
Comment on lines +65 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not render arbitrary server exception text.

The route contract at apps/console/src/app/api/chat/projects/route.ts:12-20 returns raw caught Error.message. The new flow stores and displays it. The 160-character limit restricts size but does not remove internal or sensitive content.

  • apps/console/src/components/chat/ChatPage.tsx#L65-L81: retain only a server-authored, display-safe reason or a mapped wire code.
  • apps/console/src/components/chat/ChatPage.tsx#L471-L471: pass only the sanitized reason into degradationFor.
📍 Affects 1 file
  • apps/console/src/components/chat/ChatPage.tsx#L65-L81 (this comment)
  • apps/console/src/components/chat/ChatPage.tsx#L471-L471
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/components/chat/ChatPage.tsx` around lines 65 - 81, Sanitize
the reason produced by the error-mapping flow around the visible wire-error
handling before exposing it to the UI: retain only server-authored display-safe
reasons or mapped wire-code text, never raw caught Error.message values. At
apps/console/src/components/chat/ChatPage.tsx lines 65-81, update the mapping to
exclude arbitrary server exception text; at line 471, pass only that sanitized
reason into degradationFor.

}

function connectionFor(status: number | null, error?: string | null): ConnectionState {
Expand Down Expand Up @@ -459,6 +468,7 @@ export function ChatPage({
? degradationFor(loadError.code, {
door: loadError.door,
status: loadError.status,
reason: loadError.reason,
})
: connection === 'disconnected'
? degradationFor('console_data_api_unreachable', transportOrigin)
Expand Down
53 changes: 53 additions & 0 deletions apps/console/src/lib/degradation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,59 @@ describe('degradationFor origin evidence', () => {
expect(chat.detail).toContain('502');
});

// The failing route knows why it failed and says so in its body. Dropping
// that left the reader with a category ("answered 502") and no cause, which
// is what turned a real 502 into another round trip.
it('surfaces the upstream reason alongside the door and status', () => {
const result = degradationFor('project_catalog_failed', {
door: '/api/chat/projects',
status: 502,
reason: 'objects/query failed: 502',
});
expect(result.cause).toBe('The chat project list could not be read.');
expect(result.detail).toContain('/api/chat/projects');
expect(result.detail).toContain('502');
expect(result.detail).toContain('objects/query failed');
});

// CS15 still holds: a bare wire code is not prose, so it renders as its
// sentence rather than as the identifier.
it('translates a wire-code reason into its sentence', () => {
const result = degradationFor('project_catalog_failed', {
door: '/api/chat/projects',
status: 502,
reason: 'console_data_api_unreachable',
});
expect(result.detail).toContain('The data API is unreachable.');
expect(result.detail).not.toContain('console_data_api_unreachable');
});

it('bounds a runaway reason so a stack trace cannot become the banner', () => {
const result = degradationFor('project_catalog_failed', {
door: '/api/chat/projects',
status: 502,
reason: 'x'.repeat(500),
});
expect(result.detail!.length).toBeLessThan(320);
expect(result.detail).toContain('...');
});

it('every chat route error code has a sentence of its own', () => {
const emitted = [
'project_catalog_failed', 'project_write_failed', 'project_select_failed',
'thread_catalog_failed', 'thread_read_failed', 'thread_create_failed',
'thread_update_failed', 'thread_not_found', 'attachment_upload_failed',
'file_required', 'invalid_body', 'tenant_connector_unavailable',
'web_search_requires_principal', 'console_chat_wire_failed',
'web_search_unavailable',
Comment on lines +182 to +187

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 Include every emitted chat error in the coverage test

The asserted list omits activeProjectId_required, which /api/chat/projects emits at apps/console/src/app/api/chat/projects/route.ts:53 when the PUT body has no active project ID. That code is also absent from WIRE_MAP, so this case still falls through to the generic degradation sentence while the new test claiming to cover every chat route code passes; include this emitted code and its user-facing mapping, or derive the test inputs from a shared exhaustive source.

Useful? React with 👍 / 👎.

];
Comment on lines +181 to +188
const generic = degradationFor('a_code_that_is_not_mapped_at_all').cause;
for (const code of emitted) {
expect(degradationFor(code).cause, `${code} fell through to the generic sentence`)
.not.toBe(generic);
}
});
Comment on lines +180 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test uniqueness between emitted chat failure sentences.

Line 190 compares each cause only with the unmapped fallback. Two emitted codes can share the same cause and this test still passes. Collect the emitted causes and assert that their set size equals emitted.length.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/degradation.test.ts` around lines 180 - 194, Update the
test named “every chat route error code has a sentence of its own” to collect
each emitted code’s cause and assert that the set of causes has size equal to
emitted.length. Keep the existing generic-fallback assertion, but add the
uniqueness check so duplicate sentences among mapped codes fail.


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
133 changes: 127 additions & 6 deletions apps/console/src/lib/degradation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export type DegradationOrigin = {
host?: string;
/** HTTP status, when the request was answered at all. Omit for no answer. */
status?: number;
/**
* What the failing route said went wrong, when it said anything.
*
* A route that wraps an inner failure knows the reason and puts it in its
* body; dropping it leaves the reader with a category ("answered 502") and
* no cause. Rendered through `readableReason`, which keeps CS15 intact.
*/
reason?: string;
};

type DegradationTemplate =
Expand Down Expand Up @@ -104,6 +112,86 @@ const WIRE_MAP: Record<string, DegradationTemplate> = {
actionLabel: 'Retry',
door: 'The chat wire',
},
// Every code the /api/chat/* routes can emit. Before these, all of them fell
// through to GENERIC_UNAVAILABLE, so a failed project read, a failed thread
// write and a missing attachment all said "This surface cannot render right
// now." An unmapped code is not a neutral default: it is a sentence that
// cannot be acted on.
project_catalog_failed: {
level: 'unavailable',
cause: 'The chat project list could not be read.',
actionLabel: 'Retry',
door: 'The chat project catalog',
},
project_write_failed: {
level: 'unavailable',
cause: 'That chat project could not be saved.',
actionLabel: 'Retry',
door: 'The chat project catalog',
},
project_select_failed: {
level: 'unavailable',
cause: 'That project could not be made active.',
actionLabel: 'Retry',
door: 'The chat project catalog',
},
thread_catalog_failed: {
level: 'unavailable',
cause: 'The thread list could not be read.',
actionLabel: 'Retry',
door: 'The chat thread catalog',
},
Comment on lines +132 to +143
thread_read_failed: {
level: 'unavailable',
cause: 'That thread could not be read.',
actionLabel: 'Retry',
door: 'The chat thread catalog',
},
thread_create_failed: {
level: 'unavailable',
cause: 'That thread could not be created.',
actionLabel: 'Retry',
door: 'The chat thread catalog',
},
thread_update_failed: {
level: 'unavailable',
cause: 'That thread could not be saved.',
actionLabel: 'Retry',
door: 'The chat thread catalog',
},
thread_not_found: {
level: 'unavailable',
cause: 'That thread no longer exists.',
door: 'The chat thread catalog',
},
attachment_upload_failed: {
level: 'unavailable',
cause: 'That attachment could not be uploaded.',
actionLabel: 'Retry',
door: 'The chat attachment upload',
},
file_required: {
level: 'unavailable',
cause: 'That upload arrived without a file.',
door: 'The chat attachment upload',
},
invalid_body: {
level: 'unavailable',
cause: 'The console sent a request this route could not read.',
door: 'The chat wire',
},
tenant_connector_unavailable: {
level: 'unavailable',
cause: 'No connector is available for this tenant.',
actionLabel: 'Open Account',
door: 'The chat wire',
},
web_search_requires_principal: {
level: 'unavailable',
cause: 'Web search needs a signed-in principal.',
actionLabel: 'Open Account',
door: 'Web search',
},
web_search_unavailable: {
level: 'reduced',
cause: 'Web search is unavailable.',
Expand Down Expand Up @@ -202,31 +290,64 @@ export function degradationFor(
* credential problem rather than an outage. Returns undefined when there is
* nothing concrete to say, so no caller is forced to show an empty line.
*/
/**
* Render an upstream reason without leaking a wire code to the reader.
*
* Inner failures often surface as a bare code (`console_data_api_unreachable`)
* rather than prose. CS15 keeps codes off the screen, so a code is translated
* to its sentence; anything else is real prose already and passes through,
* bounded so a stack trace cannot become the banner.
*/
function readableReason(reason: string | undefined): string | undefined {
const trimmed = reason?.trim();
if (!trimmed) return undefined;
const rendered = /^[a-z][a-z0-9_]*$/.test(trimmed)
// An unmapped bare code has no sentence to show, and inventing one would
// hide the only identifier the reader could search for.
? (WIRE_MAP[trimmed]?.cause ?? trimmed)
Comment on lines +304 to +307

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 Keep unmapped wire codes out of the banner

When an inner API returns a newly introduced or otherwise unmapped code as its reason, this fallback returns the identifier verbatim, and describeOrigin appends it directly to the user-facing banner. That contradicts this module's CS15 invariant that unmapped wire codes use generic prose and are reported only in development; fall back to the generic sentence or omit the reason instead of exposing trimmed.

Useful? React with 👍 / 👎.

: trimmed;
// Bound at the exit, not inside one branch. The code-shaped test matches any
// run of lowercase, so a long token took the other path and escaped the cap.
return rendered.length > 160 ? `${rendered.slice(0, 157)}...` : rendered;
Comment on lines +301 to +311

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep unknown wire identifiers out of the rendered detail.

Line 307 returns trimmed when a bare reason is not in WIRE_MAP. describeOrigin then displays that identifier to the user. This breaks the documented CS15 behavior and the PR requirement for unmapped codes.

Replace this fallback with safe generic prose. Add a test with an unmapped bare origin.reason.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/degradation.ts` around lines 301 - 311, The fallback in
readableReason for unmapped bare identifiers must return safe generic prose
instead of exposing trimmed. Update readableReason while preserving mapped-code
and sentence-like reason handling, then add coverage for an unmapped bare
origin.reason through describeOrigin or the relevant public path, asserting the
identifier is not rendered.

}

export function describeOrigin(origin: DegradationOrigin | undefined): string | undefined {
if (!origin) return undefined;
const { door, host, status } = origin;
if (!door && !host && status === undefined) return undefined;
const reason = readableReason(origin.reason);
if (!door && !host && status === undefined) return reason;

const subject = [door ?? 'The request', host ? `at ${host}` : null].filter(Boolean).join(' ');
// The reason is the innermost thing known about the failure, so it goes last
// and never replaces the door and status a reader needs to locate it.
const withReason = (sentence: string) => (reason ? `${sentence} ${reason}` : sentence);

if (status === undefined) {
return `${subject} did not answer. That is DNS, the network, or a blocked origin, not a status code.`;
return withReason(
`${subject} did not answer. That is DNS, the network, or a blocked origin, not a status code.`,
);
}
if (status === 401) {
return `${subject} answered 401. The request was not authenticated, which is a credential problem rather than an outage.`;
return withReason(
`${subject} answered 401. The request was not authenticated, which is a credential problem rather than an outage.`,
);
}
if (status === 403) {
// Not the same failure as 401, and saying so matters. connectionFor maps
// 403 to 'identity-refused', and the objects proxy returns it for
// active_workspace_claim_required and active_workspace_membership_refused.
// In those cases the credential is fine and simply does not reach this
// workspace, so "fix your credential" would send the reader the wrong way.
return `${subject} answered 403. The credential was accepted but refused for this workspace, which is not an outage.`;
return withReason(
`${subject} answered 403. The credential was accepted but refused for this workspace, which is not an outage.`,
);
}
if (status === 404) {
return `${subject} answered 404. Check the configured URL before assuming the service is down.`;
return withReason(
`${subject} answered 404. Check the configured URL before assuming the service is down.`,
);
}
return `${subject} answered ${status}.`;
return withReason(`${subject} answered ${status}.`);
}

/** Collapse a list of missing capability codes into one reduced marker. */
Expand Down
Loading