-
Notifications
You must be signed in to change notification settings - Fork 0
fix(console): say why a chat surface failed, not just that it did #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The asserted list omits 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
|
|
||
| it('still keeps the wire code out of what the user sees', () => { | ||
| const result = degradationFor('console_data_api_unreachable', { | ||
| door: '/api/objects/views', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = | ||
|
|
@@ -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.', | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an inner API returns a newly introduced or otherwise unmapped code as its reason, this fallback returns the identifier verbatim, and 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Replace this fallback with safe generic prose. Add a test with an unmapped bare 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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. */ | ||
|
|
||
There was a problem hiding this comment.
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-20returns raw caughtError.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 intodegradationFor.📍 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