Skip to content

Commit 4a7bfdc

Browse files
authored
fix: implement proper error handling. (#1115)
* fix: centralize userAction error handling * test: add tests for simple logic * fix: forward errors back to the client * fix: auth pop up now shows instead of thinking * refactor: remove redundant checks * docs: improve comment * fix: clean install deps * fix: check underlying error properly
1 parent 91bfef8 commit 4a7bfdc

File tree

3 files changed

+105
-25
lines changed

3 files changed

+105
-25
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
InsertToCursorPositionParams,
2424
TextDocumentEdit,
2525
InlineChatResult,
26+
CancellationToken,
27+
CancellationTokenSource,
2628
} from '@aws/language-server-runtimes/server-interface'
2729
import { TestFeatures } from '@aws/language-server-runtimes/testing'
2830
import * as assert from 'assert'
@@ -43,6 +45,8 @@ import { AdditionalContextProvider } from './context/addtionalContextProvider'
4345
import { ContextCommandsProvider } from './context/contextCommandsProvider'
4446
import { ChatDatabase } from './tools/chatDb/chatDb'
4547
import { LocalProjectContextController } from '../../shared/localProjectContextController'
48+
import { CancellationError } from '@aws/lsp-core'
49+
import { ToolApprovalException } from './tools/toolShared'
4650

4751
describe('AgenticChatController', () => {
4852
const mockTabId = 'tab-1'
@@ -916,7 +920,7 @@ describe('AgenticChatController', () => {
916920
})
917921
})
918922

919-
it('returns a ResponseError if sendMessage returns an error', async () => {
923+
it('propagates error message to final chat result', async () => {
920924
generateAssistantResponseStub.callsFake(() => {
921925
throw new Error('Error')
922926
})
@@ -926,10 +930,13 @@ describe('AgenticChatController', () => {
926930
mockCancellationToken
927931
)
928932

929-
assert.ok(chatResult instanceof ResponseError)
933+
// These checks will fail if a response error is returned.
934+
const typedChatResult = chatResult as ChatResult
935+
assert.strictEqual(typedChatResult.type, 'answer')
936+
assert.strictEqual(typedChatResult.body, 'Error')
930937
})
931938

932-
it('returns a auth follow up action if sendMessage returns an auth error', async () => {
939+
it('returns an auth follow up action if model request returns an auth error', async () => {
933940
generateAssistantResponseStub.callsFake(() => {
934941
throw new Error('Error')
935942
})
@@ -942,7 +949,8 @@ describe('AgenticChatController', () => {
942949

943950
const chatResult = await chatResultPromise
944951

945-
sinon.assert.callCount(testFeatures.lsp.sendProgress, 1) // called for loading message
952+
// called once for error message propagation and once for loading message.
953+
sinon.assert.callCount(testFeatures.lsp.sendProgress, 2)
946954
assert.deepStrictEqual(chatResult, utils.createAuthFollowUpResult('full-auth'))
947955
})
948956

@@ -1844,6 +1852,23 @@ ${' '.repeat(8)}}
18441852

18451853
sinon.assert.calledOnce(tabBarActionStub)
18461854
})
1855+
1856+
it('determines when an error is a user action', function () {
1857+
const nonUserAction = new Error('User action error')
1858+
const cancellationError = new CancellationError('user')
1859+
const rejectionError = new ToolApprovalException()
1860+
const tokenSource = new CancellationTokenSource()
1861+
1862+
assert.ok(!chatController.isUserAction(nonUserAction))
1863+
assert.ok(chatController.isUserAction(cancellationError))
1864+
assert.ok(chatController.isUserAction(rejectionError))
1865+
1866+
assert.ok(!chatController.isUserAction(nonUserAction, tokenSource.token))
1867+
1868+
tokenSource.cancel()
1869+
1870+
assert.ok(chatController.isUserAction(nonUserAction, tokenSource.token))
1871+
})
18471872
})
18481873

18491874
// The body may include text-based progress updates from tool invocations.

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import { ListDirectory, ListDirectoryParams } from './tools/listDirectory'
101101
import { FsWrite, FsWriteParams, getDiffChanges } from './tools/fsWrite'
102102
import { ExecuteBash, ExecuteBashOutput, ExecuteBashParams } from './tools/executeBash'
103103
import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared'
104+
import { ModelServiceException } from './errors'
104105
import { FileSearch, FileSearchParams } from './tools/fileSearch'
105106
import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch'
106107

@@ -337,8 +338,20 @@ export class AgenticChatController implements ChatHandlers {
337338
chatResultStream
338339
)
339340
} catch (err) {
340-
// TODO: On ToolValidationException, we want to show custom mynah-ui components making it clear it was cancelled.
341-
if (CancellationError.isUserCancelled(err) || err instanceof ToolApprovalException) {
341+
// HACK: the chat-client needs to have a partial event with the associated messageId sent before it can accept the final result.
342+
// Without this, the `thinking` indicator never goes away.
343+
// Note: buttons being explicitly empty is required for this hack to work.
344+
const errorMessageId = `error-message-id-${uuid()}`
345+
await this.#sendProgressToClient(
346+
{
347+
type: 'answer',
348+
body: '',
349+
messageId: errorMessageId,
350+
buttons: [],
351+
},
352+
params.partialResultToken
353+
)
354+
if (this.isUserAction(err, token)) {
342355
/**
343356
* when the session is aborted it generates an error.
344357
* we need to resolve this error with an answer so the
@@ -347,9 +360,11 @@ export class AgenticChatController implements ChatHandlers {
347360
return {
348361
type: 'answer',
349362
body: '',
363+
messageId: errorMessageId,
364+
buttons: [],
350365
}
351366
}
352-
return this.#handleRequestError(err, params.tabId, metric)
367+
return this.#handleRequestError(err, errorMessageId, params.tabId, metric)
353368
}
354369
}
355370

@@ -445,10 +460,9 @@ export class AgenticChatController implements ChatHandlers {
445460
}
446461

447462
// Phase 3: Request Execution
448-
this.#debug(`Request Input: ${JSON.stringify(currentRequestInput)}`)
449-
450-
const response = await session.generateAssistantResponse(currentRequestInput)
451-
this.#debug(`Response received for iteration ${iterationCount}:`, JSON.stringify(response.$metadata))
463+
const response = await this.fetchModelResponse(currentRequestInput, i =>
464+
session.generateAssistantResponse(i)
465+
)
452466

453467
// remove the temp loading message when we have response
454468
if (loadingMessageId) {
@@ -699,8 +713,8 @@ export class AgenticChatController implements ChatHandlers {
699713
this.#features.chat.sendChatUpdate({ tabId, state: { inProgress: false } })
700714
loadingMessageId = undefined
701715
}
702-
// If we did not approve a tool to be used or the user stopped the response, bubble this up to interrupt agentic loop
703-
if (CancellationError.isUserCancelled(err) || err instanceof ToolApprovalException) {
716+
717+
if (this.isUserAction(err, token)) {
704718
if (err instanceof ToolApprovalException && toolUse.name === 'executeBash') {
705719
if (buttonBlockId) {
706720
await chatResultStream.overwriteResultBlock(
@@ -714,9 +728,6 @@ export class AgenticChatController implements ChatHandlers {
714728
throw err
715729
}
716730
const errMsg = err instanceof Error ? err.message : 'unknown error'
717-
await chatResultStream.writeResultBlock({
718-
body: toolErrorMessage(toolUse, errMsg),
719-
})
720731
this.#log(`Error running tool ${toolUse.name}:`, errMsg)
721732
results.push({
722733
toolUseId: toolUse.toolUseId,
@@ -729,6 +740,34 @@ export class AgenticChatController implements ChatHandlers {
729740
return results
730741
}
731742

743+
/**
744+
* Determines if error is thrown as a result of a user action (Ex. rejecting tool, stop button)
745+
* @param err
746+
* @returns
747+
*/
748+
isUserAction(err: unknown, token?: CancellationToken): boolean {
749+
return (
750+
CancellationError.isUserCancelled(err) ||
751+
err instanceof ToolApprovalException ||
752+
(token?.isCancellationRequested ?? false)
753+
)
754+
}
755+
756+
async fetchModelResponse<RequestType, ResponseType>(
757+
requestInput: RequestType,
758+
makeRequest: (requestInput: RequestType) => Promise<ResponseType>
759+
): Promise<ResponseType> {
760+
this.#debug(`Q Backend Request: ${JSON.stringify(requestInput)}`)
761+
try {
762+
const response = await makeRequest(requestInput)
763+
this.#debug(`Q Backend Response: ${JSON.stringify(response)}`)
764+
return response
765+
} catch (e) {
766+
this.#features.logging.error(`Error in call: ${JSON.stringify(e)}`)
767+
throw new ModelServiceException(e as Error)
768+
}
769+
}
770+
732771
#validateToolResult(toolUse: ToolUse, result: ToolResultContentBlock) {
733772
let maxToolResponseSize
734773
switch (toolUse.name) {
@@ -1111,6 +1150,7 @@ export class AgenticChatController implements ChatHandlers {
11111150
*/
11121151
#handleRequestError(
11131152
err: any,
1153+
errorMessageId: string,
11141154
tabId: string,
11151155
metric: Metric<CombinedConversationEvent>
11161156
): ChatResult | ResponseError<ChatResult> {
@@ -1119,12 +1159,23 @@ export class AgenticChatController implements ChatHandlers {
11191159
this.#telemetryController.emitMessageResponseError(tabId, metric.metric, err.requestId, err.message)
11201160
}
11211161

1122-
if (err instanceof AmazonQServicePendingSigninError) {
1162+
// return non-model errors back to the client as errors
1163+
if (!(err instanceof ModelServiceException)) {
1164+
this.#log(`unknown error ${err instanceof Error ? JSON.stringify(err) : 'unknown'}`)
1165+
this.#debug(`stack ${err instanceof Error ? JSON.stringify(err.stack) : 'unknown'}`)
1166+
this.#debug(`cause ${err instanceof Error ? JSON.stringify(err.cause) : 'unknown'}`)
1167+
return new ResponseError<ChatResult>(
1168+
LSPErrorCodes.RequestFailed,
1169+
err instanceof Error ? err.message : 'Unknown request error'
1170+
)
1171+
}
1172+
1173+
if (err.cause instanceof AmazonQServicePendingSigninError) {
11231174
this.#log(`Q Chat SSO Connection error: ${getErrorMessage(err)}`)
11241175
return createAuthFollowUpResult('full-auth')
11251176
}
11261177

1127-
if (err instanceof AmazonQServicePendingProfileError) {
1178+
if (err.cause instanceof AmazonQServicePendingProfileError) {
11281179
this.#log(`Q Chat SSO Connection error: ${getErrorMessage(err)}`)
11291180
const followUpResult = createAuthFollowUpResult('use-supported-auth')
11301181
// Access first element in array
@@ -1140,13 +1191,14 @@ export class AgenticChatController implements ChatHandlers {
11401191
return createAuthFollowUpResult(authFollowType)
11411192
}
11421193

1143-
this.#log(`Q api request error ${err instanceof Error ? JSON.stringify(err) : 'unknown'}`)
1144-
this.#debug(`Q api request error stack ${err instanceof Error ? JSON.stringify(err.stack) : 'unknown'}`)
1145-
this.#debug(`Q api request error cause ${err instanceof Error ? JSON.stringify(err.cause) : 'unknown'}`)
1146-
return new ResponseError<ChatResult>(
1147-
LSPErrorCodes.RequestFailed,
1148-
err instanceof Error ? err.message : 'Unknown request error'
1149-
)
1194+
const backendError = err.cause
1195+
// Send the backend error message directly to the client to be displayed in chat.
1196+
return {
1197+
type: 'answer',
1198+
body: backendError.message,
1199+
messageId: errorMessageId,
1200+
buttons: [],
1201+
}
11501202
}
11511203

11521204
async onInlineChatPrompt(
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class ModelServiceException {
2+
public constructor(public readonly cause: Error) {}
3+
}

0 commit comments

Comments
 (0)