Skip to content

Commit b2153f0

Browse files
committed
fix(agent): validate workdir fallback
1 parent f0d1319 commit b2153f0

File tree

7 files changed

+131
-18
lines changed

7 files changed

+131
-18
lines changed

src/main/presenter/newAgentPresenter/index.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,12 @@ export class NewAgentPresenter {
150150
if (input.generationSettings) {
151151
initConfig.generationSettings = input.generationSettings
152152
}
153-
await this.initializeSessionRuntime(agent, sessionId, initConfig)
153+
try {
154+
await this.initializeSessionRuntime(agent, sessionId, initConfig)
155+
} catch (error) {
156+
await this.cleanupFailedSessionInitialization(agent, sessionId)
157+
throw error
158+
}
154159
console.log(`[NewAgentPresenter] agent.initSession done`)
155160

156161
// Bind to window and emit activated
@@ -224,13 +229,18 @@ export class NewAgentPresenter {
224229
const sessionId = this.sessionManager.create(agentId, 'New Chat', projectDir, {
225230
isDraft: true
226231
})
227-
await this.ensureSessionRuntimeInitialized(agent, sessionId, {
228-
agentId,
229-
providerId: 'acp',
230-
modelId: agentId,
231-
projectDir,
232-
permissionMode
233-
})
232+
try {
233+
await this.ensureSessionRuntimeInitialized(agent, sessionId, {
234+
agentId,
235+
providerId: 'acp',
236+
modelId: agentId,
237+
projectDir,
238+
permissionMode
239+
})
240+
} catch (error) {
241+
await this.cleanupFailedSessionInitialization(agent, sessionId)
242+
throw error
243+
}
234244
record = this.sessionManager.get(sessionId)
235245
if (!record) {
236246
throw new Error(`Failed to read created ACP draft session: ${sessionId}`)
@@ -1377,9 +1387,26 @@ export class NewAgentPresenter {
13771387
projectDir: normalizedProjectDir,
13781388
error
13791389
})
1390+
throw error
13801391
}
13811392
}
13821393

1394+
private async cleanupFailedSessionInitialization(
1395+
agent: IAgentImplementation,
1396+
sessionId: string
1397+
): Promise<void> {
1398+
try {
1399+
await agent.destroySession(sessionId)
1400+
} catch (cleanupError) {
1401+
console.warn(
1402+
`[NewAgentPresenter] Failed to cleanup session runtime after initialization error ${sessionId}:`,
1403+
cleanupError
1404+
)
1405+
}
1406+
1407+
this.sessionManager.delete(sessionId)
1408+
}
1409+
13831410
private buildExportConversation(
13841411
session: SessionRecord,
13851412
providerId: string,

src/main/presenter/skillPresenter/skillExecutionService.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,29 @@ export class SkillExecutionService {
181181
const resolvedWorkdir = await this.resolveConversationWorkdir(conversationId)
182182
const normalizedWorkdir = resolvedWorkdir?.trim()
183183
if (normalizedWorkdir) {
184-
return path.resolve(normalizedWorkdir)
184+
const resolvedPath = path.resolve(normalizedWorkdir)
185+
try {
186+
const stat = await fs.promises.stat(resolvedPath)
187+
if (stat.isDirectory()) {
188+
return resolvedPath
189+
}
190+
logger.warn(
191+
'[SkillExecutionService] Conversation workdir is not a directory, falling back to skill root',
192+
{
193+
conversationId,
194+
invalidWorkdir: resolvedPath
195+
}
196+
)
197+
} catch (error) {
198+
logger.warn(
199+
'[SkillExecutionService] Conversation workdir is invalid, falling back to skill root',
200+
{
201+
conversationId,
202+
invalidWorkdir: resolvedPath,
203+
error
204+
}
205+
)
206+
}
185207
}
186208
} catch (error) {
187209
logger.warn('[SkillExecutionService] Failed to resolve conversation workdir', {

test/main/presenter/llmProviderPresenter/acp/acpProcessManager.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'path'
12
import { describe, expect, it, vi } from 'vitest'
23
import {
34
AcpProcessManager,
@@ -218,7 +219,7 @@ describe('AcpProcessManager config cache fallback', () => {
218219
expect.objectContaining({
219220
sessionId: 'missing-session',
220221
command: 'pwd',
221-
cwd: '/tmp/deepchat-acp/sessions'
222+
cwd: expect.stringContaining(path.join('deepchat-acp', 'sessions'))
222223
})
223224
)
224225
})

test/main/presenter/llmProviderPresenter/acp/acpTerminalManager.test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'fs'
2+
import path from 'path'
23
import { beforeEach, describe, expect, it, vi } from 'vitest'
34
import { AcpTerminalManager } from '@/presenter/llmProviderPresenter/acp/acpTerminalManager'
45
import { spawn } from 'node-pty'
@@ -14,6 +15,14 @@ vi.mock('electron', () => ({
1415
}))
1516

1617
describe('AcpTerminalManager', () => {
18+
const getShellExpectation = () =>
19+
process.platform === 'win32'
20+
? expect.stringMatching(/powershell/i)
21+
: expect.stringMatching(/bash/i)
22+
23+
const getArgsExpectation = (command: string) =>
24+
process.platform === 'win32' ? ['-NoLogo', '-Command', command] : ['-c', command]
25+
1726
const createPty = () => ({
1827
onData: vi.fn(),
1928
onExit: vi.fn(),
@@ -36,10 +45,10 @@ describe('AcpTerminalManager', () => {
3645
})
3746

3847
expect(spawn).toHaveBeenCalledWith(
39-
'/bin/bash',
40-
['-c', 'pwd'],
48+
getShellExpectation(),
49+
getArgsExpectation('pwd'),
4150
expect.objectContaining({
42-
cwd: '/tmp/workspace'
51+
cwd: expect.stringContaining(path.normalize('/tmp/workspace'))
4352
})
4453
)
4554
})
@@ -52,14 +61,14 @@ describe('AcpTerminalManager', () => {
5261
command: 'pwd'
5362
})
5463

55-
expect(fs.mkdirSync).toHaveBeenCalledWith('/tmp/deepchat-acp/terminals', {
64+
expect(fs.mkdirSync).toHaveBeenCalledWith(path.normalize('/tmp/deepchat-acp/terminals'), {
5665
recursive: true
5766
})
5867
expect(spawn).toHaveBeenCalledWith(
59-
'/bin/bash',
60-
['-c', 'pwd'],
68+
getShellExpectation(),
69+
getArgsExpectation('pwd'),
6170
expect.objectContaining({
62-
cwd: '/tmp/deepchat-acp/terminals'
71+
cwd: expect.stringContaining(path.normalize('/tmp/deepchat-acp/terminals'))
6372
})
6473
)
6574
})

test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,36 @@ describe('NewAgentPresenter', () => {
542542
}
543543
)
544544
})
545+
546+
it('aborts ACP session creation when workdir sync fails', async () => {
547+
configPresenter.getAcpAgents.mockResolvedValue([
548+
{ id: 'acp-coder', name: 'ACP Coder', command: 'acp-coder' }
549+
])
550+
deepChatAgent.getSessionState.mockResolvedValue({
551+
status: 'idle',
552+
providerId: 'acp',
553+
modelId: 'acp-coder',
554+
permissionMode: 'full_access'
555+
})
556+
llmProviderPresenter.setAcpWorkdir.mockRejectedValueOnce(new Error('sync failed'))
557+
558+
await expect(
559+
presenter.createSession(
560+
{
561+
agentId: 'acp-coder',
562+
message: 'Hello ACP',
563+
projectDir: '/tmp/workspace',
564+
providerId: 'acp',
565+
modelId: 'acp-coder'
566+
},
567+
1
568+
)
569+
).rejects.toThrow('sync failed')
570+
571+
expect(deepChatAgent.destroySession).toHaveBeenCalledWith('mock-session-id')
572+
expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id')
573+
expect(deepChatAgent.processMessage).not.toHaveBeenCalled()
574+
})
545575
})
546576

547577
describe('sendMessage', () => {

test/main/presenter/skillPresenter/skillExecutionService.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ describe('SkillExecutionService', () => {
4848
beforeEach(() => {
4949
vi.clearAllMocks()
5050
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
51+
vi.mocked(fs.promises.stat).mockResolvedValue({
52+
isDirectory: () => true
53+
} as never)
5154

5255
skillPresenter = {
5356
getActiveSkills: vi.fn().mockResolvedValue(['ocr']),
@@ -138,6 +141,26 @@ describe('SkillExecutionService', () => {
138141
expect(plan.cwd).toBe('/skills/ocr')
139142
})
140143

144+
it('falls back to skill root cwd when the resolved session workdir is not a directory', async () => {
145+
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
146+
isDirectory: () => false
147+
} as never)
148+
vi.spyOn(service as never, 'resolveRuntimeCommand' as never).mockResolvedValue({
149+
command: 'uv',
150+
mode: 'uv'
151+
})
152+
153+
const plan = await (service as never).buildSpawnPlan(
154+
{
155+
skill: 'ocr',
156+
script: 'scripts/run.py'
157+
},
158+
'conv-1'
159+
)
160+
161+
expect(plan.cwd).toBe('/skills/ocr')
162+
})
163+
141164
it('falls back to bundled uv for python auto runtime', async () => {
142165
vi.spyOn(service as never, 'hasCommand' as never).mockResolvedValue(false)
143166
vi.spyOn(service as never, 'getBundledRuntimeCommand' as never).mockImplementation(

test/setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ vi.mock('fs', () => {
5353
readFile: vi.fn(),
5454
writeFile: vi.fn(),
5555
mkdir: vi.fn(),
56-
readdir: vi.fn()
56+
readdir: vi.fn(),
57+
stat: vi.fn()
5758
}
5859
}
5960

0 commit comments

Comments
 (0)