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
38 changes: 36 additions & 2 deletions src/main/presenter/agentRuntimePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,10 @@ export class AgentRuntimePresenter implements IAgentImplementation {
if (!state) {
throw new Error(`Session ${sessionId} not found`)
}
const projectDir =
options && Object.prototype.hasOwnProperty.call(options, 'projectDir')
? this.resolveProjectDir(sessionId, options.projectDir)
: this.resolveProjectDir(sessionId)

const shouldClaimImmediately =
((options?.source ?? 'send') === 'send' && this.isAwaitingToolQuestionFollowUp(sessionId)) ||
Expand All @@ -403,7 +407,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {

if (record.state === 'claimed') {
void this.processMessage(sessionId, record.payload, {
projectDir: this.resolveProjectDir(sessionId),
projectDir,
pendingQueueItemId: record.id,
pendingQueueItemSource: options?.source ?? 'send'
})
Expand Down Expand Up @@ -1006,6 +1010,17 @@ export class AgentRuntimePresenter implements IAgentImplementation {
this.invalidateSystemPromptCache(sessionId)
}

async setSessionProjectDir(sessionId: string, projectDir: string | null): Promise<void> {
const normalized = this.normalizeProjectDir(projectDir)
const previous = this.sessionProjectDirs.has(sessionId)
? (this.sessionProjectDirs.get(sessionId) ?? null)
: this.resolvePersistedSessionProjectDir(sessionId)
this.sessionProjectDirs.set(sessionId, normalized)
if (previous !== normalized) {
this.invalidateSystemPromptCache(sessionId)
}
}

async getPermissionMode(sessionId: string): Promise<PermissionMode> {
const state = this.runtimeState.get(sessionId)
if (state) {
Expand Down Expand Up @@ -4358,6 +4373,19 @@ export class AgentRuntimePresenter implements IAgentImplementation {
return normalized ? normalized : null
}

private resolvePersistedSessionProjectDir(sessionId: string): string | null {
try {
const session = this.sqlitePresenter.newSessionsTable?.get(sessionId)
return this.normalizeProjectDir(session?.project_dir ?? null)
} catch (error) {
console.warn('[DeepChatAgent] Failed to resolve persisted project directory:', {
sessionId,
error
})
return null
}
}

private resolveProjectDir(sessionId: string, incoming?: string | null): string | null {
if (incoming !== undefined) {
const normalized = this.normalizeProjectDir(incoming)
Expand All @@ -4368,6 +4396,12 @@ export class AgentRuntimePresenter implements IAgentImplementation {
}
return normalized
}
return this.sessionProjectDirs.get(sessionId) ?? null
if (this.sessionProjectDirs.has(sessionId)) {
return this.sessionProjectDirs.get(sessionId) ?? null
}

const persisted = this.resolvePersistedSessionProjectDir(sessionId)
this.sessionProjectDirs.set(sessionId, persisted)
return persisted
}
}
40 changes: 32 additions & 8 deletions src/main/presenter/agentSessionPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,9 +415,14 @@ export class AgentSessionPresenter {
if (normalizedInput.text.trim() || (normalizedInput.files?.length ?? 0) > 0) {
console.log(`[AgentSessionPresenter] firing queuePendingInput (non-blocking)`)
if (agent.queuePendingInput) {
agent.queuePendingInput(sessionId, normalizedInput, { source: 'send' }).catch((err) => {
console.error('[AgentSessionPresenter] queuePendingInput failed:', err)
})
agent
.queuePendingInput(sessionId, normalizedInput, {
source: 'send',
projectDir
})
.catch((err) => {
console.error('[AgentSessionPresenter] queuePendingInput failed:', err)
})
} else {
agent.processMessage(sessionId, normalizedInput, { projectDir }).catch((err) => {
console.error('[AgentSessionPresenter] processMessage failed:', err)
Expand Down Expand Up @@ -732,7 +737,10 @@ export class AgentSessionPresenter {
session.projectDir ?? null
)
if (agent.queuePendingInput) {
await agent.queuePendingInput(sessionId, normalizedInput, { source: 'send' })
await agent.queuePendingInput(sessionId, normalizedInput, {
source: 'send',
projectDir: session.projectDir ?? null
})
if (!hadMessages && !wasDraft) {
void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '')
}
Expand Down Expand Up @@ -794,7 +802,10 @@ export class AgentSessionPresenter {
currentSession.agentId,
currentSession.projectDir ?? null
)
return await agent.queuePendingInput(sessionId, normalizedInput, { source: 'queue' })
return await agent.queuePendingInput(sessionId, normalizedInput, {
source: 'queue',
projectDir: currentSession.projectDir ?? null
})
}

async updateQueuedInput(sessionId: string, itemId: string, content: string | SendMessageInput) {
Expand Down Expand Up @@ -1696,12 +1707,25 @@ export class AgentSessionPresenter {
throw new Error(`Session not found: ${sessionId}`)
}

this.sessionManager.update(sessionId, { projectDir })
const agent = await this.resolveAgentImplementation(session.agentId)
const state = await agent.getSessionState(sessionId)
const providerId =
state?.providerId?.trim() ||
((await this.getAgentType(session.agentId)) === 'acp' ? 'acp' : '')
const normalizedProjectDir = projectDir?.trim() || null
this.assertAcpSessionHasWorkdir(providerId, normalizedProjectDir)

this.sessionManager.update(sessionId, { projectDir: normalizedProjectDir })

// Sync environment for new project dir
if (projectDir) {
this.sqlitePresenter.newEnvironmentsTable.syncPath(projectDir)
if (normalizedProjectDir) {
this.sqlitePresenter.newEnvironmentsTable.syncPath(normalizedProjectDir)
}
Comment on lines +1718 to +1723

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resync the previous environment path too.

syncPath() only recomputes the path you pass in. After Line 1718 moves a session from /old to
/new or clears the directory, the aggregate row for /old is left stale because only the new path
is refreshed here.

♻️ Suggested fix
+    const previousProjectDir = session.projectDir ?? null
     this.sessionManager.update(sessionId, { projectDir: normalizedProjectDir })

-    if (normalizedProjectDir) {
+    if (previousProjectDir !== normalizedProjectDir) {
+      this.sqlitePresenter.newEnvironmentsTable.syncPath(previousProjectDir)
+    }
+    if (normalizedProjectDir) {
       this.sqlitePresenter.newEnvironmentsTable.syncPath(normalizedProjectDir)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/agentSessionPresenter/index.ts` around lines 1718 - 1723,
When updating the session's projectDir you must also resync the old path so it
doesn't remain stale: before calling this.sessionManager.update(sessionId, {
projectDir: normalizedProjectDir }) read the existing session (e.g., const prev
= this.sessionManager.get(sessionId) or equivalent) and if prev?.projectDir and
prev.projectDir !== normalizedProjectDir call
this.sqlitePresenter.newEnvironmentsTable.syncPath(prev.projectDir); then
proceed to update and still call syncPath(normalizedProjectDir) for the new
value (or call syncPath(null) when clearing) so both old and new aggregate rows
are refreshed.


if (agent.setSessionProjectDir) {
await agent.setSessionProjectDir(sessionId, normalizedProjectDir)
}
await this.syncAcpSessionWorkdir(providerId, sessionId, session.agentId, normalizedProjectDir)
Comment on lines +1718 to +1728

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid committing the new workdir before downstream sync can fail.

If syncAcpSessionWorkdir() rejects, this method still leaves new_sessions.project_dir updated
from Line 1718, so callers see a failure even though subsequent reads return the new directory.
Either perform the ACP/runtime sync before persisting, or rollback the previous projectDir on
error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/presenter/agentSessionPresenter/index.ts` around lines 1718 - 1728,
The code currently updates persistent session state via
sessionManager.update(sessionId, { projectDir: normalizedProjectDir }) before
calling syncAcpSessionWorkdir(...), which can leave the DB in a
partially-applied state if the ACP/runtime sync fails; fix this by performing
the external syncs first (call
sqlitePresenter.newEnvironmentsTable.syncPath(normalizedProjectDir), await
agent.setSessionProjectDir(sessionId, normalizedProjectDir) if present, and
await syncAcpSessionWorkdir(providerId, sessionId, session.agentId,
normalizedProjectDir)) and only call sessionManager.update(...) after those
awaitable operations succeed; if you cannot reorder, implement a try/catch
around the sync calls and on error rollback the previous projectDir via
sessionManager.update(sessionId, { projectDir: previousProjectDir }) and rethrow
the error.


const updated = this.sessionManager.get(sessionId)
if (!updated) {
Expand Down
4 changes: 4 additions & 0 deletions src/shared/types/agent-interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type PendingInputEnqueueSource = 'send' | 'queue'

export interface QueuePendingInputOptions {
source?: PendingInputEnqueueSource
projectDir?: string | null
}

export interface IAgentImplementation {
Expand Down Expand Up @@ -147,6 +148,9 @@ export interface IAgentImplementation {
/** Set provider/model for this session (takes effect on next user message) */
setSessionModel?(sessionId: string, providerId: string, modelId: string): Promise<void>

/** Set project/workspace directory for this session (takes effect on next user message) */
setSessionProjectDir?(sessionId: string, projectDir: string | null): Promise<void>

/** Get permission mode for this session */
getPermissionMode?(sessionId: string): Promise<PermissionMode>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,21 @@ vi.mock('@/presenter', () => ({
vi.mock('@/lib/agentRuntime/systemEnvPromptBuilder', () => ({
buildRuntimeCapabilitiesPrompt: vi.fn(() => 'RUNTIME_CAPABILITIES'),
buildSystemEnvPrompt: vi.fn(
async (options?: { providerId?: string; modelId?: string; now?: Date }) => {
async (options?: {
providerId?: string
modelId?: string
now?: Date
workdir?: string | null
}) => {
const providerId = options?.providerId || 'unknown-provider'
const modelId = options?.modelId || 'unknown-model'
const dateText = (options?.now ?? new Date()).toDateString()
return ['ENV_BLOCK', `MODEL:${providerId}/${modelId}`, `DATE:${dateText}`].join('\n')
return [
'ENV_BLOCK',
`MODEL:${providerId}/${modelId}`,
`WORKDIR:${options?.workdir ?? ''}`,
`DATE:${dateText}`
].join('\n')
}
)
}))
Expand Down Expand Up @@ -1309,6 +1319,51 @@ describe('AgentRuntimePresenter', () => {
expect(secondCallArgs.messages[0].content).toContain('Updated user prompt')
})

it('invalidates cached prompt after session project directory update', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-05T08:00:00.000Z'))
const envBuilder = buildSystemEnvPrompt as ReturnType<typeof vi.fn>

await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' })
await agent.processMessage('s1', 'Before project update')

await agent.setSessionProjectDir('s1', '/tmp/workspace')
await agent.processMessage('s1', 'After project update')

expect(envBuilder).toHaveBeenCalledTimes(2)

const secondCallArgs = (processStream as ReturnType<typeof vi.fn>).mock.calls[1][0]
expect(secondCallArgs.messages[0].content).toContain('WORKDIR:/tmp/workspace')
})

it('uses persisted project directory when runtime state was restored from DB', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-05T08:00:00.000Z'))
sqlitePresenter.deepchatSessionsTable.get.mockReturnValue({
id: 's-restored',
provider_id: 'openai',
model_id: 'gpt-4',
permission_mode: 'full_access'
})
sqlitePresenter.newSessionsTable.get.mockReturnValue({
id: 's-restored',
agent_id: 'deepchat',
project_dir: '/tmp/restored-workspace'
})

await agent.getSessionState('s-restored')
await agent.processMessage('s-restored', 'Restored session follow-up')

const callArgs = (processStream as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(callArgs.messages[0].content).toContain('WORKDIR:/tmp/restored-workspace')
expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 's-restored',
agentWorkspacePath: '/tmp/restored-workspace'
})
)
})

it('invalidates cached prompt across natural days', async () => {
vi.useFakeTimers()
const envBuilder = buildSystemEnvPrompt as ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -2761,13 +2816,16 @@ describe('AgentRuntimePresenter', () => {
.mockReturnValue(claimedRecord)
const processSpy = vi.spyOn(agent, 'processMessage').mockResolvedValue()

const result = await agent.queuePendingInput('s1', 'Hello')
const result = await agent.queuePendingInput('s1', 'Hello', {
projectDir: '/tmp/workspace'
})

expect(queueSpy).toHaveBeenCalledWith('s1', 'Hello', { state: 'claimed' })
expect(processSpy).toHaveBeenCalledWith(
's1',
claimedRecord.payload,
expect.objectContaining({
projectDir: '/tmp/workspace',
pendingQueueItemId: claimedRecord.id
})
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function createMockDeepChatAgent() {
getMessageIds: vi.fn().mockResolvedValue([]),
getMessage: vi.fn().mockResolvedValue(null),
setSessionModel: vi.fn().mockResolvedValue(undefined),
setSessionProjectDir: vi.fn().mockResolvedValue(undefined),
getGenerationSettings: vi.fn().mockResolvedValue({
systemPrompt: 'Default prompt',
temperature: 0.7,
Expand Down Expand Up @@ -349,6 +350,34 @@ describe('AgentSessionPresenter', () => {
)
})

it('passes project directory to queued first messages', async () => {
const queuePendingInput = vi.fn().mockResolvedValue({
id: 'q1',
sessionId: 'mock-session-id',
mode: 'queue',
state: 'claimed',
payload: { text: 'Hello', files: [] },
queueOrder: 1,
claimedAt: 1,
consumedAt: null,
createdAt: 1,
updatedAt: 1
})
;(deepChatAgent as any).queuePendingInput = queuePendingInput

await presenter.createSession(
{ agentId: 'deepchat', message: 'Hello', projectDir: '/tmp/proj' },
1
)

expect(queuePendingInput).toHaveBeenCalledWith(
'mock-session-id',
{ text: 'Hello', files: [] },
{ source: 'send', projectDir: '/tmp/proj' }
)
expect(deepChatAgent.processMessage).not.toHaveBeenCalled()
})

it('emits ACTIVATED and LIST_UPDATED events', async () => {
await presenter.createSession({ agentId: 'deepchat', message: 'Hello' }, 42)

Expand Down Expand Up @@ -887,11 +916,40 @@ describe('AgentSessionPresenter', () => {
expect(queuePendingInput).toHaveBeenCalledWith(
's1',
{ text: 'Later', files: [] },
{ source: 'queue' }
{ source: 'queue', projectDir: '/tmp/workspace' }
)
})
})

describe('setSessionProjectDir', () => {
it('syncs workspace changes into the active agent runtime', async () => {
const row = {
id: 's1',
agent_id: 'deepchat',
title: 'Test',
project_dir: null as string | null,
is_pinned: 0,
is_draft: 0,
created_at: 1000,
updated_at: 1000
}
sqlitePresenter.newSessionsTable.get.mockImplementation(() => row)
sqlitePresenter.newSessionsTable.update.mockImplementation((_: string, fields: any) => {
if (fields.project_dir !== undefined) {
row.project_dir = fields.project_dir
}
})

await presenter.setSessionProjectDir('s1', '/tmp/workspace')

expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', {
project_dir: '/tmp/workspace'
})
expect(deepChatAgent.setSessionProjectDir).toHaveBeenCalledWith('s1', '/tmp/workspace')
expect(sqlitePresenter.newEnvironmentsTable.syncPath).toHaveBeenCalledWith('/tmp/workspace')
})
})

describe('ensureAcpDraftSession', () => {
it('creates draft session and prepares ACP session setup', async () => {
configPresenter.getAcpAgents.mockResolvedValue([
Expand Down