Skip to content

Commit 315f6a9

Browse files
committed
fix(main): cancel nested vision work
1 parent 498b7a5 commit 315f6a9

7 files changed

Lines changed: 141 additions & 16 deletions

File tree

src/main/presenter/deepchatAgentPresenter/index.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ const isReasoningEffort = (value: unknown): value is 'minimal' | 'low' | 'medium
108108
const isVerbosity = (value: unknown): value is 'low' | 'medium' | 'high' =>
109109
value === 'low' || value === 'medium' || value === 'high'
110110

111+
const createAbortError = (): Error => {
112+
if (typeof DOMException !== 'undefined') {
113+
return new DOMException('Aborted', 'AbortError')
114+
}
115+
116+
const error = new Error('Aborted')
117+
error.name = 'AbortError'
118+
return error
119+
}
120+
111121
export class DeepChatAgentPresenter implements IAgentImplementation {
112122
private readonly llmProviderPresenter: ILlmProviderPresenter
113123
private readonly configPresenter: IConfigPresenter
@@ -1012,6 +1022,23 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
10121022
return undefined
10131023
}
10141024

1025+
private getAbortSignalForSession(sessionId: string): AbortSignal | undefined {
1026+
return (
1027+
this.activeGenerations.get(sessionId)?.abortController.signal ??
1028+
this.abortControllers.get(sessionId)?.signal
1029+
)
1030+
}
1031+
1032+
private throwIfAbortRequested(signal?: AbortSignal): void {
1033+
if (signal?.aborted) {
1034+
throw createAbortError()
1035+
}
1036+
}
1037+
1038+
private isAbortError(error: unknown): boolean {
1039+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError')
1040+
}
1041+
10151042
private dispatchResolvedToolHook(params: {
10161043
sessionId: string
10171044
messageId: string
@@ -1433,7 +1460,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
14331460
toolName: tool.toolName,
14341461
toolArgs: tool.toolArgs,
14351462
content: tool.content,
1436-
isError: tool.isError
1463+
isError: tool.isError,
1464+
abortSignal: abortController.signal
14371465
})
14381466
},
14391467
io: {
@@ -2883,7 +2911,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
28832911
toolName,
28842912
toolArgs: toolCall.params || '{}',
28852913
content: rawData.content,
2886-
isError: rawData.isError === true
2914+
isError: rawData.isError === true,
2915+
abortSignal: this.getAbortSignalForSession(sessionId)
28872916
})
28882917
const responseText = this.toolContentToText(normalizedContent)
28892918
const prepared = await this.toolOutputGuard.prepareToolOutput({
@@ -2981,11 +3010,13 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
29813010
toolArgs: string
29823011
content: MCPToolResponse['content']
29833012
isError: boolean
3013+
abortSignal?: AbortSignal
29843014
}): Promise<MCPToolResponse['content']> {
29853015
if (params.isError) {
29863016
return params.content
29873017
}
29883018

3019+
const abortSignal = params.abortSignal ?? this.getAbortSignalForSession(params.sessionId)
29893020
const screenshotPayload = this.extractScreenshotToolPayload(
29903021
params.toolName,
29913022
params.toolArgs,
@@ -2995,12 +3026,15 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
29953026
return params.content
29963027
}
29973028

2998-
const visionModel = await this.resolveScreenshotVisionModel(params.sessionId)
2999-
if (!visionModel) {
3000-
return 'Screenshot captured, but automatic English analysis is unavailable because neither the current session model nor the agent vision model can analyze images.'
3001-
}
3002-
30033029
try {
3030+
this.throwIfAbortRequested(abortSignal)
3031+
const visionModel = await this.resolveScreenshotVisionModel(params.sessionId, abortSignal)
3032+
this.throwIfAbortRequested(abortSignal)
3033+
3034+
if (!visionModel) {
3035+
return 'Screenshot captured, but automatic English analysis is unavailable because neither the current session model nor the agent vision model can analyze images.'
3036+
}
3037+
30043038
const messages: ChatMessage[] = [
30053039
{
30063040
role: 'user',
@@ -3029,14 +3063,20 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
30293063
messages,
30303064
visionModel.modelId,
30313065
modelConfig?.temperature ?? 0.2,
3032-
Math.min(modelConfig?.maxTokens ?? 900, 900)
3066+
Math.min(modelConfig?.maxTokens ?? 900, 900),
3067+
abortSignal ? { signal: abortSignal } : undefined
30333068
)
3069+
this.throwIfAbortRequested(abortSignal)
30343070
const normalized = response.trim()
30353071
if (!normalized) {
30363072
return 'Screenshot captured, but automatic English analysis returned no usable description.'
30373073
}
30383074
return normalized
30393075
} catch (error) {
3076+
if (this.isAbortError(error)) {
3077+
return 'Screenshot captured, but automatic English analysis was canceled.'
3078+
}
3079+
30403080
const message = error instanceof Error ? error.message : String(error)
30413081
console.warn('[DeepChatAgent] Failed to normalize screenshot tool output:', {
30423082
sessionId: params.sessionId,
@@ -3110,8 +3150,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
31103150
}
31113151

31123152
private async resolveScreenshotVisionModel(
3113-
sessionId: string
3153+
sessionId: string,
3154+
abortSignal?: AbortSignal
31143155
): Promise<{ providerId: string; modelId: string } | null> {
3156+
this.throwIfAbortRequested(abortSignal)
31153157
const state = this.runtimeState.get(sessionId)
31163158
const dbSession = this.sessionStore.get(sessionId)
31173159
const agentId = this.getSessionAgentId(sessionId) ?? 'deepchat'
@@ -3120,8 +3162,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
31203162
modelId: state?.modelId ?? dbSession?.model_id,
31213163
agentId,
31223164
configPresenter: this.configPresenter,
3165+
signal: abortSignal,
31233166
logLabel: `screenshot:${sessionId}`
31243167
})
3168+
this.throwIfAbortRequested(abortSignal)
31253169

31263170
if (!resolved) {
31273171
return null
@@ -3130,6 +3174,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation {
31303174
if (resolved.source === 'agent-vision-model') {
31313175
const agentSupportsVision =
31323176
(await this.configPresenter.agentSupportsCapability?.(agentId, 'vision')) === true
3177+
this.throwIfAbortRequested(abortSignal)
31333178
if (!agentSupportsVision) {
31343179
return null
31353180
}

src/main/presenter/llmProviderPresenter/index.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ import { AcpSessionPersistence } from './acp'
3333
import { AcpProvider } from './providers/acpProvider'
3434
import type { ProviderMcpRuntimePort } from './runtimePorts'
3535

36+
const createAbortError = (): Error => {
37+
if (typeof DOMException !== 'undefined') {
38+
return new DOMException('Aborted', 'AbortError')
39+
}
40+
41+
const error = new Error('Aborted')
42+
error.name = 'AbortError'
43+
return error
44+
}
45+
3646
export class LLMProviderPresenter implements ILlmProviderPresenter {
3747
private currentProviderId: string | null = null
3848
private readonly activeStreams: Map<string, StreamState> = new Map()
@@ -258,16 +268,37 @@ export class LLMProviderPresenter implements ILlmProviderPresenter {
258268
messages: ChatMessage[],
259269
modelId: string,
260270
temperature?: number,
261-
maxTokens?: number
271+
maxTokens?: number,
272+
options?: { signal?: AbortSignal }
262273
): Promise<string> {
263274
const provider = this.getProviderInstance(providerId)
264275
let response = ''
276+
const signal = options?.signal
277+
278+
if (signal?.aborted) {
279+
throw createAbortError()
280+
}
281+
282+
const completionPromise = provider.completions(messages, modelId, temperature, maxTokens)
283+
const abortPromise =
284+
signal &&
285+
new Promise<never>((_, reject) => {
286+
const onAbort = () => reject(createAbortError())
287+
signal.addEventListener('abort', onAbort, { once: true })
288+
completionPromise.finally(() => signal.removeEventListener('abort', onAbort))
289+
})
290+
265291
try {
266-
const llmResponse = await provider.completions(messages, modelId, temperature, maxTokens)
292+
const llmResponse = await (abortPromise
293+
? Promise.race([completionPromise, abortPromise])
294+
: completionPromise)
267295
response = llmResponse.content
268296

269297
return response
270298
} catch (error) {
299+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
300+
throw error
301+
}
271302
console.error('Stream error:', error)
272303
return ''
273304
}

src/main/presenter/vision/sessionVisionResolver.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,34 @@ type SessionVisionResolverParams = {
1010
providerId?: string | null
1111
modelId?: string | null
1212
agentId?: string | null
13+
signal?: AbortSignal
1314
configPresenter: Pick<
1415
IConfigPresenter,
1516
'getModelConfig' | 'resolveDeepChatAgentConfig' | 'isKnownModel'
1617
>
1718
logLabel?: string
1819
}
1920

21+
const createAbortError = (): Error => {
22+
if (typeof DOMException !== 'undefined') {
23+
return new DOMException('Aborted', 'AbortError')
24+
}
25+
26+
const error = new Error('Aborted')
27+
error.name = 'AbortError'
28+
return error
29+
}
30+
31+
const throwIfAbortRequested = (signal?: AbortSignal): void => {
32+
if (signal?.aborted) {
33+
throw createAbortError()
34+
}
35+
}
36+
2037
export async function resolveSessionVisionTarget(
2138
params: SessionVisionResolverParams
2239
): Promise<SessionVisionTarget | null> {
40+
throwIfAbortRequested(params.signal)
2341
const sessionProviderId = params.providerId?.trim()
2442
const sessionModelId = params.modelId?.trim()
2543
const sessionModelConfig =
@@ -46,7 +64,9 @@ export async function resolveSessionVisionTarget(
4664
}
4765

4866
try {
67+
throwIfAbortRequested(params.signal)
4968
const agentConfig = await params.configPresenter.resolveDeepChatAgentConfig(agentId)
69+
throwIfAbortRequested(params.signal)
5070
const providerId = agentConfig.visionModel?.providerId?.trim()
5171
const modelId = agentConfig.visionModel?.modelId?.trim()
5272
if (providerId && modelId) {
@@ -57,6 +77,9 @@ export async function resolveSessionVisionTarget(
5777
}
5878
}
5979
} catch (error) {
80+
if (error instanceof Error && error.name === 'AbortError') {
81+
throw error
82+
}
6083
console.warn('[Vision] Failed to resolve agent vision model:', {
6184
agentId,
6285
context: params.logLabel ?? 'unknown',

src/shared/types/presenters/legacy.presenters.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1159,7 +1159,8 @@ export interface ILlmProviderPresenter {
11591159
messages: ChatMessage[],
11601160
modelId: string,
11611161
temperature?: number,
1162-
maxTokens?: number
1162+
maxTokens?: number,
1163+
options?: { signal?: AbortSignal }
11631164
): Promise<string>
11641165
getAcpWorkdir(conversationId: string, agentId: string): Promise<AcpWorkdirInfo>
11651166
setAcpWorkdir(conversationId: string, agentId: string, workdir: string | null): Promise<void>

src/shared/types/presenters/llmprovider.presenter.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ export interface ILlmProviderPresenter {
269269
messages: ChatMessage[],
270270
modelId: string,
271271
temperature?: number,
272-
maxTokens?: number
272+
maxTokens?: number,
273+
options?: { signal?: AbortSignal }
273274
): Promise<string>
274275

275276
getAcpWorkdir(conversationId: string, agentId: string): Promise<AcpWorkdirInfo>

test/main/presenter/configPresenter/anthropicProviderMigration.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, describe, expect, it, vi } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import { eventBus } from '@/eventbus'
33
import { CONFIG_EVENTS } from '../../../../src/main/events'
44

@@ -155,6 +155,10 @@ describe('getAnthropicModelSelectionKeysToClear', () => {
155155
})
156156

157157
describe('migrateLegacyDefaultVisionModelToBuiltinAgent', () => {
158+
beforeEach(() => {
159+
vi.clearAllMocks()
160+
})
161+
158162
it('migrates a valid legacy vision model with trimmed ids', () => {
159163
const store = {
160164
get: vi.fn().mockReturnValue({ providerId: ' openai ', modelId: ' gpt-4o ' }),

test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2846,7 +2846,8 @@ describe('DeepChatAgentPresenter', () => {
28462846
],
28472847
'gpt-4o',
28482848
expect.any(Number),
2849-
expect.any(Number)
2849+
expect.any(Number),
2850+
undefined
28502851
)
28512852
expect(configPresenter.resolveDeepChatAgentConfig).not.toHaveBeenCalled()
28522853
expect(result).toEqual(
@@ -2896,11 +2897,30 @@ describe('DeepChatAgentPresenter', () => {
28962897
expect.any(Array),
28972898
'gemini-2.5-flash',
28982899
expect.any(Number),
2899-
expect.any(Number)
2900+
expect.any(Number),
2901+
undefined
29002902
)
29012903
expect(normalized).toBe('English screenshot summary')
29022904
})
29032905

2906+
it('returns a cancellation message when screenshot normalization is aborted', async () => {
2907+
const abortController = new AbortController()
2908+
abortController.abort()
2909+
2910+
const normalized = await (agent as any).normalizeToolResultContent({
2911+
sessionId: 's1',
2912+
toolCallId: 'tc1',
2913+
toolName: 'cdp_send',
2914+
toolArgs: '{"method":"Page.captureScreenshot"}',
2915+
content: '{"data":"YWJj"}',
2916+
isError: false,
2917+
abortSignal: abortController.signal
2918+
})
2919+
2920+
expect(llmProvider.generateCompletionStandalone).not.toHaveBeenCalled()
2921+
expect(normalized).toBe('Screenshot captured, but automatic English analysis was canceled.')
2922+
})
2923+
29042924
it('ignores fallback agent vision models when the agent does not support vision', async () => {
29052925
sqlitePresenter.newSessionsTable.get.mockReturnValue({
29062926
id: 's1',

0 commit comments

Comments
 (0)