diff --git a/.gitignore b/.gitignore index f3d7c92f..de995fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ *.log .env .env.local diff --git a/backend/src/index.ts b/backend/src/index.ts index 877adbd8..64c393af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -335,7 +335,7 @@ sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServe sseAggregator.start() sseAggregator.setScheduledSessionsResolver( - () => scheduleService.getActiveRunSessionIds(), + () => scheduleService.getActiveRunSessions(), ) const openCodeRestartCoordinator = new OpenCodeRestartCoordinator(openCodeClient, sseAggregator) diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 5f75c5e0..4edbf378 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -44,7 +44,7 @@ import { resolveOpenCodeModel } from './opencode-models' import type { OpenCodeClient } from './opencode/client' import type { ScheduleWorktreeManager } from './schedule-worktree' import type { Repo } from '../types/repo' -import { sseAggregator, type SSEEvent } from './sse-aggregator' +import { sseAggregator, type SSEEvent, type ScheduledSessionRef } from './sse-aggregator' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' import { buildAssistantRepo } from './assistant-mode' @@ -63,13 +63,6 @@ interface SessionResponse { id: string } -interface PromptResponse { - parts?: Array<{ - type?: string - text?: string - }> -} - interface SessionMessagePart { type?: string text?: string @@ -101,22 +94,23 @@ interface SessionStatus { next?: number } -const RUN_POLL_INTERVAL_MS = 2_000 -const RUN_POLL_TIMEOUT_MS = 5 * 60_000 +const SESSION_STOPPED_ERROR = 'The session stopped without producing an assistant response. This usually means OpenCode restarted mid-run or the session was interrupted. Open the linked session to inspect any partial output and rerun if needed.' + +interface SessionSignal { + errorText: string | null + disposed: boolean +} interface SessionMonitor { - getErrorText(): string | null - isIdle(): boolean + markSubmitted(): void + nextSignal(): Promise dispose(): void } -function extractResponseText(response: PromptResponse): string { - return (response.parts ?? []) - .filter((part) => part.type === 'text' && typeof part.text === 'string') - .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') - .filter(Boolean) - .join('\n\n') -} +type AssistantOutcome = + | { kind: 'busy' } + | { kind: 'settled'; responseText: string | null; errorText: string | null } + | { kind: 'stopped'; responseText: string | null } function buildSessionTitle(job: ScheduleJob): string { return `Scheduled: ${job.name}` @@ -253,18 +247,6 @@ function buildRunStartedLog(input: { ].join('\n') } -function parsePromptResponse(responseText: string): PromptResponse | null { - if (!responseText.trim()) { - return null - } - - try { - return JSON.parse(responseText) as PromptResponse - } catch { - return null - } -} - function extractAssistantMessageText(parts: SessionMessagePart[] | undefined): string { return (parts ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') @@ -326,8 +308,19 @@ function getSessionStatusType(event: SSEEvent): string | null { } function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { - let errorText: string | null = null - let idle = false + const queued: SessionSignal[] = [] + let waiting: ((signal: SessionSignal) => void) | null = null + let disposed = false + + const push = (signal: SessionSignal): void => { + if (waiting) { + const resolve = waiting + waiting = null + resolve(signal) + return + } + queued.push(signal) + } const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { if (eventDirectory !== directory) { @@ -339,24 +332,37 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni } if (event.type === 'session.error') { - errorText = getSessionErrorText(event) ?? 'The session reported an unknown error.' + push({ errorText: getSessionErrorText(event) ?? 'The session reported an unknown error.', disposed: false }) return } - if (event.type === 'session.idle') { - idle = true - return - } - - if (event.type === 'session.status' && getSessionStatusType(event) === 'idle') { - idle = true + if (event.type === 'session.idle' || (event.type === 'session.status' && getSessionStatusType(event) === 'idle')) { + push({ errorText: null, disposed: false }) } }) return { - getErrorText: () => errorText, - isIdle: () => idle, - dispose: unsubscribe, + markSubmitted: () => { + queued.length = 0 + }, + nextSignal: () => { + const next = queued.shift() + if (next) { + return Promise.resolve(next) + } + if (disposed) { + return Promise.resolve({ errorText: null, disposed: true }) + } + return new Promise((resolve) => { waiting = resolve }) + }, + dispose: () => { + if (disposed) { + return + } + disposed = true + unsubscribe() + push({ errorText: null, disposed: true }) + }, } } @@ -375,9 +381,27 @@ export class ScheduleService { this.onJobChange = handler } - getActiveRunSessionIds(): Set { - const runs = listRunningScheduleRuns(this.db) - return new Set(runs.filter(r => r.sessionId).map(r => r.sessionId!)) + getActiveRunSessions(): ScheduledSessionRef[] { + const refs: ScheduledSessionRef[] = [] + + for (const run of listRunningScheduleRuns(this.db)) { + if (!run.sessionId) continue + + const directory = run.worktreePath ?? this.findRepoPath(run.repoId) + if (!directory) continue + + refs.push({ sessionID: run.sessionId, directory }) + } + + return refs + } + + private findRepoPath(repoId: number): string | null { + try { + return this.assertRepo(repoId).fullPath + } catch { + return null + } } listAllEnabledJobs(): ScheduleJob[] { @@ -773,7 +797,7 @@ export class ScheduleService { try { const promptResponse = await this.openCodeClient.forward({ method: 'POST', - path: `/session/${input.sessionId}/message`, + path: `/session/${input.sessionId}/prompt_async`, directory: input.directory, body: JSON.stringify({ parts: [{ type: 'text', text: await buildPromptWithSkills(input.job.prompt, input.job.skillMetadata, input.directory, this.openCodeClient) }], @@ -787,40 +811,7 @@ export class ScheduleService { throw new ScheduleServiceError(errorText || 'Failed to run scheduled prompt', 502) } - const promptBody = await promptResponse.text() - const promptResult = parsePromptResponse(promptBody) - - if (promptResult) { - const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) - if (!currentRun || currentRun.status !== 'running') { - return - } - - const finishedAt = Date.now() - const responseText = extractResponseText(promptResult) - updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { - status: 'completed', - finishedAt, - sessionId: input.sessionId, - sessionTitle: input.sessionTitle, - responseText, - logText: buildRunLog({ - job: input.job, - triggerSource: input.triggerSource, - sessionId: input.sessionId, - sessionTitle: input.sessionTitle, - responseText, - finishedAt, - }), - }) - - updateScheduleJobRunState(this.db, input.repoId, input.job.id, { - lastRunAt: finishedAt, - nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), - }) - - return - } + input.sessionMonitor.markSubmitted() await this.monitorRunCompletion({ sessionMonitor: input.sessionMonitor, @@ -905,9 +896,8 @@ export class ScheduleService { } const repo = this.assertRepo(input.repoId) - const currentMessages = await this.listSessionMessages(input.directory, input.sessionId) - const currentAssistantState = getAssistantMessageState(currentMessages) - if (currentAssistantState?.completed || currentAssistantState?.errorText) { + const currentAssistantState = await this.readSettledAssistantState(input.directory, input.sessionId) + if (currentAssistantState) { await this.finalizeRecoveredRun(input.job, { id: input.runId, repoId: input.repoId, @@ -923,7 +913,7 @@ export class ScheduleService { return } - const response = await this.waitForAssistantMessage(input.job, input.sessionId, input.sessionMonitor, input.directory) + const response = await this.waitForAssistantMessage(input.sessionId, input.sessionMonitor, input.directory) const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) if (!currentRun || currentRun.status !== 'running') { return @@ -1131,46 +1121,62 @@ export class ScheduleService { } } + /** + * An assistant message completing is not the end of a run: a multi-step agent + * settles one message per tool call. Only a session that has gone idle has + * finished, and only then is the final message guaranteed to carry its parts. + */ + private async readAssistantOutcome(directory: string, sessionId: string): Promise { + const sessionStatus = (await this.getSessionStatuses(directory))[sessionId] + if (sessionStatus && sessionStatus.type !== 'idle') { + return { kind: 'busy' } + } + + const assistantState = getAssistantMessageState(await this.listSessionMessages(directory, sessionId)) + if (assistantState?.completed || assistantState?.errorText) { + return { kind: 'settled', responseText: assistantState.responseText, errorText: assistantState.errorText } + } + + return { kind: 'stopped', responseText: assistantState?.responseText ?? null } + } + + private async readSettledAssistantState( + directory: string, + sessionId: string, + ): Promise<{ responseText: string | null; errorText: string | null } | null> { + const outcome = await this.readAssistantOutcome(directory, sessionId) + return outcome.kind === 'settled' + ? { responseText: outcome.responseText, errorText: outcome.errorText } + : null + } + private async waitForAssistantMessage( - job: ScheduleJob, sessionId: string, sessionMonitor: SessionMonitor, directory: string, ): Promise<{ responseText: string | null; errorText: string | null }> { - const startedAt = Date.now() - - while (Date.now() - startedAt < RUN_POLL_TIMEOUT_MS) { - const messages = await this.listSessionMessages(directory, sessionId) - const assistantState = getAssistantMessageState(messages) + for (;;) { + const signal = await sessionMonitor.nextSignal() - if (assistantState && (assistantState.completed || assistantState.errorText)) { + if (signal.errorText || signal.disposed) { + const messages = await this.listSessionMessages(directory, sessionId) return { - responseText: assistantState.responseText, - errorText: assistantState.errorText, + responseText: getAssistantMessageState(messages)?.responseText ?? null, + errorText: signal.errorText ?? SESSION_STOPPED_ERROR, } } - const sessionErrorText = sessionMonitor.getErrorText() - if (sessionErrorText) { - return { - responseText: null, - errorText: sessionErrorText, - } - } + const outcome = await this.readAssistantOutcome(directory, sessionId) - if (sessionMonitor.isIdle()) { - return { - responseText: null, - errorText: 'The session became idle without producing an assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', - } + if (outcome.kind === 'busy') { + continue } - await Bun.sleep(RUN_POLL_INTERVAL_MS) - } + if (outcome.kind === 'settled') { + return { responseText: outcome.responseText, errorText: outcome.errorText } + } - return { - responseText: null, - errorText: 'Timed out waiting for the assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', + return { responseText: outcome.responseText, errorText: SESSION_STOPPED_ERROR } } } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index cb19c3ea..f8a3bfb4 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -25,6 +25,11 @@ export interface PendingActionsFetcher { getJson(path: string, opts?: { directory?: string; signal?: AbortSignal }): Promise } +export interface ScheduledSessionRef { + sessionID: string + directory: string +} + interface PendingPermission { id: string sessionID: string @@ -58,7 +63,7 @@ class SSEAggregator { private started = false private pendingActionsFetcher: PendingActionsFetcher | null = null private passwordResolver: OpenCodePasswordResolver | null = null - private scheduledSessionsResolver: (() => Set) | null = null + private scheduledSessionsResolver: (() => ScheduledSessionRef[]) | null = null private constructor() {} @@ -70,7 +75,7 @@ class SSEAggregator { this.passwordResolver = resolver } - setScheduledSessionsResolver(resolver: () => Set): void { + setScheduledSessionsResolver(resolver: () => ScheduledSessionRef[]): void { this.scheduledSessionsResolver = resolver } @@ -210,11 +215,12 @@ class SSEAggregator { await Promise.allSettled(tasks) } - private async replaySessionStatusesForAllClients(): Promise { + private async replaySessionStatusesForTrackedDirectories(): Promise { const fetcher = this.pendingActionsFetcher if (!fetcher) return - const directories = new Set() + const scheduledByDirectory = this.getScheduledSessionsByDirectory() + const directories = new Set(scheduledByDirectory.keys()) this.clients.forEach((client) => { client.directories.forEach(dir => directories.add(dir)) }) @@ -222,13 +228,14 @@ class SSEAggregator { if (directories.size === 0) return logger.info(`replay: replaying session statuses for ${directories.size} directory(ies) after upstream reconnect`) await Promise.allSettled(Array.from(directories).map(directory => - this.replaySessionStatusesForDirectory(directory, fetcher) + this.replaySessionStatusesForDirectory(directory, fetcher, scheduledByDirectory.get(directory)) )) } private async replaySessionStatusesForDirectory( directory: string, fetcher: PendingActionsFetcher, + scheduledSessionIDs?: Set, ): Promise { let statuses: SessionStatusMap try { @@ -240,7 +247,8 @@ class SSEAggregator { if (!statuses) return - const previouslyActive = new Set(this.activeSessions.get(directory) ?? []) + const tracked = new Set(this.activeSessions.get(directory) ?? []) + scheduledSessionIDs?.forEach(sessionID => tracked.add(sessionID)) const nowActive = new Set() let replayed = 0 @@ -253,7 +261,7 @@ class SSEAggregator { } let cleared = 0 - for (const sessionID of previouslyActive) { + for (const sessionID of tracked) { if (nowActive.has(sessionID)) continue const data = JSON.stringify({ directory, payload: { type: 'session.status', properties: { sessionID, status: { type: 'idle' } } } }) this.handleUpstreamMessage(data) @@ -352,7 +360,7 @@ class SSEAggregator { this.everConnected = true if (wasConnectedBefore) { void this.replayPendingActionsForAllClients() - void this.replaySessionStatusesForAllClients() + void this.replaySessionStatusesForTrackedDirectories() } } @@ -529,7 +537,24 @@ class SSEAggregator { } getScheduledSessionIds(): Set { - return this.scheduledSessionsResolver?.() ?? new Set() + return new Set(this.getScheduledSessions().map(ref => ref.sessionID)) + } + + private getScheduledSessions(): ScheduledSessionRef[] { + return this.scheduledSessionsResolver?.() ?? [] + } + + private getScheduledSessionsByDirectory(): Map> { + const byDirectory = new Map>() + for (const ref of this.getScheduledSessions()) { + let sessions = byDirectory.get(ref.directory) + if (!sessions) { + sessions = new Set() + byDirectory.set(ref.directory, sessions) + } + sessions.add(ref.sessionID) + } + return byDirectory } getActiveDirectories(): string[] { diff --git a/backend/test/db/schedules.permission.test.ts b/backend/test/db/schedules.permission.test.ts index 5caf2d52..1842c4ad 100644 --- a/backend/test/db/schedules.permission.test.ts +++ b/backend/test/db/schedules.permission.test.ts @@ -41,6 +41,7 @@ describe('schedule permission config persistence', () => { it('round-trips permissionConfig when set on create', () => { const config = { allowExternalDirectory: true, + allowQuestions: false, bashDenyPatterns: ['rm -rf /'], } const created = createScheduleJob(db, 1, baseInput({ permissionConfig: config })) @@ -68,6 +69,7 @@ describe('schedule permission config persistence', () => { const config = { allowExternalDirectory: false, + allowQuestions: true, bashDenyPatterns: ['sudo rm -rf *'], } const updated = updateScheduleJob(db, 1, created.id, baseInput({ permissionConfig: config })) @@ -80,7 +82,7 @@ describe('schedule permission config persistence', () => { }) it('clears permissionConfig when set to null on update', () => { - const config = { allowExternalDirectory: true, bashDenyPatterns: [] } + const config = { allowExternalDirectory: true, allowQuestions: false, bashDenyPatterns: [] } const created = createScheduleJob(db, 1, baseInput({ permissionConfig: config })) expect(created.permissionConfig).toEqual(config) @@ -90,7 +92,7 @@ describe('schedule permission config persistence', () => { }) it('preserves permissionConfig when updating other fields', () => { - const config = { allowExternalDirectory: false, bashDenyPatterns: ['rm -rf *'] } + const config = { allowExternalDirectory: false, allowQuestions: false, bashDenyPatterns: ['rm -rf *'] } const created = createScheduleJob(db, 1, baseInput({ permissionConfig: config })) expect(created.permissionConfig).toEqual(config) diff --git a/backend/test/services/schedule-permissions.test.ts b/backend/test/services/schedule-permissions.test.ts index f2ed7bad..ad6e3d57 100644 --- a/backend/test/services/schedule-permissions.test.ts +++ b/backend/test/services/schedule-permissions.test.ts @@ -7,23 +7,46 @@ describe('buildSchedulePermissionRuleset', () => { expect(result[0]).toEqual({ permission: '*', pattern: '*', action: 'allow' }) expect(result).toContainEqual({ permission: 'external_directory', pattern: '*', action: 'deny' }) + expect(result).toContainEqual({ permission: 'question', pattern: '*', action: 'deny' }) for (const pattern of DEFAULT_DESTRUCTIVE_BASH_PATTERNS) { expect(result).toContainEqual({ permission: 'bash', pattern, action: 'deny' }) } }) + it('denies the question tool when questions are not allowed so unattended runs cannot stall', () => { + const result = buildSchedulePermissionRuleset({ + allowExternalDirectory: true, + allowQuestions: false, + bashDenyPatterns: [], + }) + + expect(result).toEqual([ + { permission: '*', pattern: '*', action: 'allow' }, + { permission: 'question', pattern: '*', action: 'deny' }, + ]) + }) + it('returns only the allow-all baseline when all permissions are granted', () => { - const result = buildSchedulePermissionRuleset({ allowExternalDirectory: true, bashDenyPatterns: [] }) + const result = buildSchedulePermissionRuleset({ + allowExternalDirectory: true, + allowQuestions: true, + bashDenyPatterns: [], + }) expect(result).toEqual([{ permission: '*', pattern: '*', action: 'allow' }]) }) - it('includes a single custom bash deny pattern alongside external_directory deny', () => { - const result = buildSchedulePermissionRuleset({ allowExternalDirectory: false, bashDenyPatterns: ['rm -rf *'] }) + it('includes a single custom bash deny pattern alongside external_directory and question denies', () => { + const result = buildSchedulePermissionRuleset({ + allowExternalDirectory: false, + allowQuestions: false, + bashDenyPatterns: ['rm -rf *'], + }) expect(result).toEqual([ { permission: '*', pattern: '*', action: 'allow' }, { permission: 'external_directory', pattern: '*', action: 'deny' }, + { permission: 'question', pattern: '*', action: 'deny' }, { permission: 'bash', pattern: 'rm -rf *', action: 'deny' }, ]) }) diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index c8428f23..71547766 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -210,7 +210,7 @@ describe('ScheduleService permission ruleset in session creation', () => { if (path === '/session' && method === 'POST') { return jsonResponse({ id: 'ses-perm-1' }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { + if (path.match(/^\/session\/[\w-]+\/prompt_async$/) && method === 'POST') { return textResponse('') } if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { @@ -235,7 +235,7 @@ describe('ScheduleService permission ruleset in session creation', () => { }) it('sends custom permission ruleset when job has custom permissionConfig', async () => { - const customConfig = { allowExternalDirectory: true, bashDenyPatterns: [] } + const customConfig = { allowExternalDirectory: true, allowQuestions: true, bashDenyPatterns: [] } mocks.getScheduleJobById.mockReturnValue({ ...baseJob, permissionConfig: customConfig }) const runWithSession: ScheduleRun = { @@ -250,7 +250,7 @@ describe('ScheduleService permission ruleset in session creation', () => { if (path === '/session' && method === 'POST') { return jsonResponse({ id: 'ses-perm-2' }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { + if (path.match(/^\/session\/[\w-]+\/prompt_async$/) && method === 'POST') { return textResponse('') } if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { @@ -289,7 +289,7 @@ describe('ScheduleService permission ruleset in session creation', () => { if (path === '/session' && method === 'POST') { return jsonResponse({ id: 'ses-perm-3' }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { + if (path.match(/^\/session\/[\w-]+\/prompt_async$/) && method === 'POST') { return textResponse('') } if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index aa22a063..622ca700 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -217,10 +217,14 @@ describe('ScheduleService', () => { return Promise.resolve(jsonResponse({ id: 'ses-run-1' })) } - if (path === '/session/ses-run-1/message' && method === 'POST') { + if (path === '/session/ses-run-1/prompt_async' && method === 'POST') { return Promise.resolve(textResponse('')) } + if (path === '/session/status' && method === 'GET') { + return Promise.resolve(jsonResponse({ 'ses-run-1': { type: 'idle' } })) + } + if (path === '/session/ses-run-1/message' && method === 'GET') { return Promise.resolve(jsonResponse([ { @@ -259,7 +263,7 @@ describe('ScheduleService', () => { ) }) - it('sends session and message JSON POSTs with Content-Type: application/json', async () => { + it('sends session and prompt_async JSON POSTs with Content-Type: application/json', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) const runWithSession: ScheduleRun = { ...baseRun, @@ -273,8 +277,16 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return jsonResponse({ id: 'ses-content-type' }) } - if (path === '/session/ses-content-type/message' && method === 'POST') { - return textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }] })) + if (path === '/session/ses-content-type/prompt_async' && method === 'POST') { + return new Response(null, { status: 204 }) + } + if (path === '/session/ses-content-type/message' && method === 'GET') { + return jsonResponse([ + { + info: { role: 'assistant', sessionID: 'ses-content-type', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Done.' }], + }, + ]) } throw new Error(`Unexpected forward request: ${method} ${path}`) }) @@ -292,14 +304,14 @@ describe('ScheduleService', () => { expect(mocks.forward).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', - path: '/session/ses-content-type/message', + path: '/session/ses-content-type/prompt_async', headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }), ) }) }) - it('completes a run immediately when the prompt endpoint returns JSON', async () => { + it('submits without holding the request open and completes from the session completion event', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) const runWithSession: ScheduleRun = { ...baseRun, @@ -308,6 +320,7 @@ describe('ScheduleService', () => { logText: 'Run started. Waiting for assistant response...', } + let assistantCompleted = false mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) routeForward(({ path, method }) => { @@ -315,10 +328,27 @@ describe('ScheduleService', () => { return Promise.resolve(jsonResponse({ id: 'ses-run-2' })) } - if (path === '/session/ses-run-2/message' && method === 'POST') { - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Immediate status summary.' }], - }))) + if (path === '/session/ses-run-2/prompt_async' && method === 'POST') { + return Promise.resolve(new Response(null, { status: 204 })) + } + + if (path === '/session/status' && method === 'GET') { + return Promise.resolve(jsonResponse( + assistantCompleted ? { 'ses-run-2': { type: 'idle' } } : { 'ses-run-2': { type: 'busy' } }, + )) + } + + if (path === '/session/ses-run-2/message' && method === 'GET') { + return Promise.resolve(jsonResponse([ + { + info: { + role: 'assistant', + sessionID: 'ses-run-2', + time: assistantCompleted ? { completed: Date.now() } : {}, + }, + parts: [{ type: 'text', text: 'Event driven summary.' }], + }, + ])) } throw new Error(`Unexpected proxy request: ${method} ${path}`) @@ -326,6 +356,17 @@ describe('ScheduleService', () => { await service.runJob(42, 7, 'manual') + await vi.waitFor(() => { + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ method: 'GET', path: '/session/status' }), + ) + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + assistantCompleted = true + const emit = mocks.onEvent.mock.calls[0]?.[0] as (directory: string, event: unknown) => void + emit(repo.fullPath, { type: 'session.idle', properties: { sessionID: 'ses-run-2' } }) + await vi.waitFor(() => { expect(mocks.updateScheduleRun).toHaveBeenCalledWith( expect.anything(), @@ -334,12 +375,76 @@ describe('ScheduleService', () => { 5, expect.objectContaining({ status: 'completed', - responseText: 'Immediate status summary.', + responseText: 'Event driven summary.', }), ) }) }) + it('keeps waiting when an intermediate assistant step completes while the session is still busy', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-multi', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + let sessionIdle = false + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ id: 'ses-multi' })) + } + + if (path === '/session/ses-multi/prompt_async' && method === 'POST') { + return Promise.resolve(new Response(null, { status: 204 })) + } + + if (path === '/session/status' && method === 'GET') { + return Promise.resolve(jsonResponse({ 'ses-multi': { type: sessionIdle ? 'idle' : 'busy' } })) + } + + if (path === '/session/ses-multi/message' && method === 'GET') { + return Promise.resolve(jsonResponse(sessionIdle + ? [ + { info: { role: 'assistant', sessionID: 'ses-multi', time: { completed: Date.now() } }, parts: [{ type: 'tool', tool: 'bash' }] }, + { info: { role: 'assistant', sessionID: 'ses-multi', time: { completed: Date.now() } }, parts: [{ type: 'text', text: 'Final answer.' }] }, + ] + : [ + { info: { role: 'assistant', sessionID: 'ses-multi', time: { completed: Date.now() } }, parts: [{ type: 'tool', tool: 'bash' }] }, + ])) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + const emit = mocks.onEvent.mock.calls[0]?.[0] as (directory: string, event: unknown) => void + + emit(repo.fullPath, { type: 'session.idle', properties: { sessionID: 'ses-multi' } }) + await vi.waitFor(() => { + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ method: 'GET', path: '/session/status' }), + ) + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + sessionIdle = true + emit(repo.fullPath, { type: 'session.idle', properties: { sessionID: 'ses-multi' } }) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed', responseText: 'Final answer.' }), + ) + }) + }) + it('rejects a new run when the job already has a running entry', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) @@ -399,7 +504,7 @@ describe('ScheduleService', () => { return Promise.resolve(jsonResponse({ id: 'ses-run-6' })) } - if (path === '/session/ses-run-6/message' && method === 'POST') { + if (path === '/session/ses-run-6/prompt_async' && method === 'POST') { return Promise.resolve(textResponse('Provider unavailable', 500)) } @@ -654,29 +759,25 @@ describe('ScheduleService', () => { sessionId: 'ses-run-9', sessionTitle: 'Scheduled: Weekly engineering summary', } - let messageRequests = 0 + let sessionIdle = false mocks.listRunningScheduleRuns.mockReturnValue([resumedRun]) mocks.getScheduleRunById.mockReturnValue(resumedRun) routeForward(({ path, method }) => { if (path === '/session/ses-run-9/message' && method === 'GET') { - messageRequests += 1 - - if (messageRequests === 1) { - return Promise.resolve(jsonResponse([])) - } - - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'Recovered after reconnect' }], - }, - ])) + return Promise.resolve(jsonResponse(sessionIdle + ? [ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Recovered after reconnect' }], + }, + ] + : [])) } if (path === '/session/status' && method === 'GET') { return Promise.resolve(jsonResponse({ - 'ses-run-9': { type: 'busy' }, + 'ses-run-9': { type: sessionIdle ? 'idle' : 'busy' }, })) } @@ -685,6 +786,10 @@ describe('ScheduleService', () => { await service.recoverRunningRuns() + sessionIdle = true + const emitResume = mocks.onEvent.mock.calls[0]?.[0] as (directory: string, event: unknown) => void + emitResume(repo.fullPath, { type: 'session.idle', properties: { sessionID: 'ses-run-9' } }) + await vi.waitFor(() => { expect(mocks.updateScheduleRun).toHaveBeenCalledWith( expect.anything(), @@ -879,7 +984,7 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-1' })) } - if (path === '/session/ses-skills-1/message' && method === 'POST') { + if (path === '/session/ses-skills-1/prompt_async' && method === 'POST') { capturedPromptBody = body return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }], @@ -928,7 +1033,7 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-2' })) } - if (path === '/session/ses-skills-2/message' && method === 'POST') { + if (path === '/session/ses-skills-2/prompt_async' && method === 'POST') { capturedPromptBody = body return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }], @@ -971,7 +1076,7 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-3' })) } - if (path === '/session/ses-skills-3/message' && method === 'POST') { + if (path === '/session/ses-skills-3/prompt_async' && method === 'POST') { capturedPromptBody = body return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }], @@ -1015,7 +1120,7 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-4' })) } - if (path === '/session/ses-skills-4/message' && method === 'POST') { + if (path === '/session/ses-skills-4/prompt_async' && method === 'POST') { capturedPromptBody = body return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }], @@ -1059,7 +1164,7 @@ describe('ScheduleService', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-5' })) } - if (path === '/session/ses-skills-5/message' && method === 'POST') { + if (path === '/session/ses-skills-5/prompt_async' && method === 'POST') { capturedPromptBody = body return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }], @@ -1124,7 +1229,7 @@ describe('ScheduleService worktree isolation', () => { expect(directory).toBe(worktreePath) return Promise.resolve(jsonResponse({ id: 'ses-wt-1' })) } - if (path === '/session/ses-wt-1/message' && method === 'POST') { + if (path === '/session/ses-wt-1/prompt_async' && method === 'POST') { expect(directory).toBe(worktreePath) return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Worktree run done.' }], @@ -1163,7 +1268,7 @@ describe('ScheduleService worktree isolation', () => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-wt-1' })) } - if (path === '/session/ses-wt-1/message' && method === 'POST') { + if (path === '/session/ses-wt-1/prompt_async' && method === 'POST') { return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Worktree run done.' }], }))) @@ -1203,7 +1308,7 @@ describe('ScheduleService worktree isolation', () => { capturedDirectory = directory return Promise.resolve(jsonResponse({ id: 'ses-inline-1' })) } - if (path === '/session/ses-inline-1/message' && method === 'POST') { + if (path === '/session/ses-inline-1/prompt_async' && method === 'POST') { return Promise.resolve(textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Inline run done.' }], }))) diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index 5be9fcbe..1af400a5 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -215,7 +215,7 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { const clientA = createCapturingClient() sseAggregator.addClient('status-1', clientA.callback, clientA.writeFrame, ['/repo/a']) - await (sseAggregator as any).replaySessionStatusesForAllClients() + await (sseAggregator as any).replaySessionStatusesForTrackedDirectories() await flushReplay() const parsed = clientA.frames.map(f => JSON.parse(f.replace(/^event: message\ndata: /, '').trim()) as { @@ -238,7 +238,7 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { sseAggregator.setPendingActionsFetcher(makeFetcher({ '/repo/a': { statuses: {} } })) - await (sseAggregator as any).replaySessionStatusesForAllClients() + await (sseAggregator as any).replaySessionStatusesForTrackedDirectories() await flushReplay() const parsed = clientA.frames.map(f => JSON.parse(f.replace(/^event: message\ndata: /, '').trim()) as { @@ -253,13 +253,70 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { const clientA = createCapturingClient() sseAggregator.addClient('status-3', clientA.callback, clientA.writeFrame, ['/repo/a']) - await (sseAggregator as any).replaySessionStatusesForAllClients() + await (sseAggregator as any).replaySessionStatusesForTrackedDirectories() await flushReplay() expect(clientA.frames).toHaveLength(0) }) }) +describe('SSEAggregator scheduled session replay without a connected client', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('replays a scheduled directory that no browser client is subscribed to', async () => { + sseAggregator.setPendingActionsFetcher(makeFetcher({ + '/worktrees/run-1': { statuses: { 'ses-sched': { type: 'busy' } } }, + })) + sseAggregator.setScheduledSessionsResolver(() => [ + { sessionID: 'ses-sched', directory: '/worktrees/run-1' }, + ]) + + const seen: Array<{ directory: string; type: string; sessionID: string; status: string }> = [] + sseAggregator.onEvent((directory, event) => { + const properties = event.properties as { sessionID?: string; status?: { type?: string } } + seen.push({ + directory, + type: event.type, + sessionID: properties.sessionID ?? '', + status: properties.status?.type ?? '', + }) + }) + + await (sseAggregator as any).replaySessionStatusesForTrackedDirectories() + await flushReplay() + + expect(seen).toEqual([ + { directory: '/worktrees/run-1', type: 'session.status', sessionID: 'ses-sched', status: 'busy' }, + ]) + }) + + it('emits idle for a scheduled session that finished while the stream was down and was never marked active', async () => { + sseAggregator.setPendingActionsFetcher(makeFetcher({ + '/worktrees/run-2': { statuses: {} }, + })) + sseAggregator.setScheduledSessionsResolver(() => [ + { sessionID: 'ses-finished', directory: '/worktrees/run-2' }, + ]) + + const idle: string[] = [] + sseAggregator.onEvent((_directory, event) => { + const properties = event.properties as { sessionID?: string; status?: { type?: string } } + if (event.type === 'session.status' && properties.status?.type === 'idle') { + idle.push(properties.sessionID ?? '') + } + }) + + await (sseAggregator as any).replaySessionStatusesForTrackedDirectories() + await flushReplay() + + expect(idle).toEqual(['ses-finished']) + }) +}) + describe('SSEAggregator directory-indexed broadcast', () => { beforeEach(() => { sseAggregator.shutdown() diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index b3be2af0..ab545906 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -30,6 +30,8 @@ type GeneralTabProps = { repoOptions: ComboboxOption[] allowExternalDirectory: boolean onAllowExternalDirectoryChange: (value: boolean) => void + allowQuestions: boolean + onAllowQuestionsChange: (value: boolean) => void bashDenyPatterns: string[] onBashDenyPatternsChange: (value: string[]) => void } @@ -70,6 +72,8 @@ export function GeneralTab({ repoOptions, allowExternalDirectory, onAllowExternalDirectoryChange, + allowQuestions, + onAllowQuestionsChange, bashDenyPatterns, onBashDenyPatternsChange, }: GeneralTabProps) { @@ -178,6 +182,15 @@ export function GeneralTab({ +
+
+
+

Allow questions

+ +
+ +
+
diff --git a/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx index b74851e8..6f14a474 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.permissions.test.tsx @@ -99,6 +99,11 @@ function getExternalDirSwitch() { return screen.getAllByRole('switch')[1] } +/** The allow-questions switch is the 3rd switch (index 2), after Enabled and external directory */ +function getAllowQuestionsSwitch() { + return screen.getAllByRole('switch')[2] +} + describe('ScheduleJobDialog — permission config', () => { beforeEach(() => { vi.clearAllMocks() @@ -127,6 +132,7 @@ describe('ScheduleJobDialog — permission config', () => { const switchInput = getExternalDirSwitch() expect(switchInput).not.toBeChecked() + expect(getAllowQuestionsSwitch()).not.toBeChecked() }) it('initializes permission config from an existing job', async () => { @@ -138,6 +144,7 @@ describe('ScheduleJobDialog — permission config', () => { const job = getDefaultJob({ permissionConfig: { allowExternalDirectory: true, + allowQuestions: true, bashDenyPatterns: [...customPatterns], }, }) @@ -160,6 +167,7 @@ describe('ScheduleJobDialog — permission config', () => { const switchInput = getExternalDirSwitch() expect(switchInput).toBeChecked() + expect(getAllowQuestionsSwitch()).toBeChecked() }) it('submits permissionConfig in the create payload', async () => { @@ -198,6 +206,44 @@ describe('ScheduleJobDialog — permission config', () => { const payload = onSubmit.mock.calls[0][0] expect(payload.permissionConfig).toEqual({ allowExternalDirectory: true, + allowQuestions: false, + bashDenyPatterns: [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS], + }) + }) + + it('submits allowQuestions true when the questions toggle is enabled', async () => { + const onSubmit = vi.fn() + const onOpenChange = vi.fn() + const user = userEvent.setup() + + render( + , + { wrapper: createWrapper() }, + ) + + await navigateToGeneralTab(user) + await fillRequiredFields(user) + + await navigateToGeneralTab(user) + + await user.click(getAllowQuestionsSwitch()) + + const submitButton = screen.getByRole('button', { name: /Create schedule/i }) + await user.click(submitButton) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const payload = onSubmit.mock.calls[0][0] + expect(payload.permissionConfig).toEqual({ + allowExternalDirectory: false, + allowQuestions: true, bashDenyPatterns: [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS], }) }) @@ -239,6 +285,7 @@ describe('ScheduleJobDialog — permission config', () => { const payload = onSubmit.mock.calls[0][0] expect(payload.permissionConfig).toEqual({ allowExternalDirectory: false, + allowQuestions: false, bashDenyPatterns: ['rm -rf *', 'sudo *'], }) }) @@ -251,6 +298,7 @@ describe('ScheduleJobDialog — permission config', () => { const job = getDefaultJob({ permissionConfig: { allowExternalDirectory: false, + allowQuestions: true, bashDenyPatterns: ['rm -rf *', 'git push --force*'], }, }) @@ -288,6 +336,7 @@ describe('ScheduleJobDialog — permission config', () => { const payload = onSubmit.mock.calls[0][0] expect(payload.permissionConfig).toEqual({ allowExternalDirectory: true, + allowQuestions: true, bashDenyPatterns: ['rm -rf *', 'git push --force*', 'sudo *'], }) }) diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 8d1c677a..3fb962ce 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -65,6 +65,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const initialSkillNotesRef = useRef(undefined) const [branch, setBranch] = useState('') const [allowExternalDirectory, setAllowExternalDirectory] = useState(false) + const [allowQuestions, setAllowQuestions] = useState(false) const [bashDenyPatterns, setBashDenyPatterns] = useState([...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) const [templateDialogOpen, setTemplateDialogOpen] = useState(false) const [editingTemplate, setEditingTemplate] = useState(undefined) @@ -222,6 +223,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, initialSkillNotesRef.current = initialSkillNotes setBranch(job?.branch ?? '') setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false) + setAllowQuestions(job?.permissionConfig?.allowQuestions ?? false) setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) }, [job, open]) @@ -265,6 +267,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, branch: branch.trim() || null, permissionConfig: { allowExternalDirectory, + allowQuestions, bashDenyPatterns: bashDenyPatterns.map((p) => p.trim()).filter(Boolean), }, ...(shouldIncludeSkillMetadata ? { @@ -347,6 +350,8 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, repoOptions={repoOptions} allowExternalDirectory={allowExternalDirectory} onAllowExternalDirectoryChange={setAllowExternalDirectory} + allowQuestions={allowQuestions} + onAllowQuestionsChange={setAllowQuestions} bashDenyPatterns={bashDenyPatterns} onBashDenyPatternsChange={setBashDenyPatterns} /> diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 187609f6..ee6cf6fa 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -132,18 +132,36 @@ export function SessionDetail() { const opcodeUrl = OPENCODE_API_ENDPOINT; - const repoDirectory = repo?.fullPath; const sessionRouteSuffix = isAssistantSession ? '?assistant=1' : ''; - const { isConnected, isReconnecting } = useSSE(opcodeUrl, repoDirectory, sessionId); + const repoDirectory = repo?.fullPath; + const [resolvedSessionDirectory, setResolvedSessionDirectory] = useState<{ sessionId: string; directory: string } | null>(null); + const sessionDirectory = ( + resolvedSessionDirectory && resolvedSessionDirectory.sessionId === sessionId + ? resolvedSessionDirectory.directory + : undefined + ) ?? repoDirectory; - const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory, { fallbackPoll: !isConnected }); const { data: session, isLoading: sessionLoading, error: sessionQueryError } = useSession( opcodeUrl, sessionId, - repoDirectory, + sessionDirectory, ); + useEffect(() => { + const directory = session?.directory; + if (!sessionId || !directory) return; + setResolvedSessionDirectory((current) => ( + current?.sessionId === sessionId && current.directory === directory + ? current + : { sessionId, directory } + )); + }, [sessionId, session?.directory]); + + const { isConnected, isReconnecting } = useSSE(opcodeUrl, sessionDirectory, sessionId); + + const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, sessionDirectory, { fallbackPoll: !isConnected }); + const messages = useMemo(() => { if (!rawMessages) return undefined const revertMessageID = session?.revert?.messageID @@ -164,10 +182,10 @@ export function SessionDetail() { contentVersion: messagesContentVersion, onScrollStateChange: setShowScrollButton }); - const abortSession = useAbortSession(opcodeUrl, repoDirectory, sessionId); - const updateSession = useUpdateSession(opcodeUrl, repoDirectory); - const createSession = useCreateSession(opcodeUrl, repoDirectory); - const { model, modelString } = useModelSelection(opcodeUrl, repoDirectory); + const abortSession = useAbortSession(opcodeUrl, sessionDirectory, sessionId); + const updateSession = useUpdateSession(opcodeUrl, sessionDirectory); + const createSession = useCreateSession(opcodeUrl, sessionDirectory); + const { model, modelString } = useModelSelection(opcodeUrl, sessionDirectory); const isEditingMessage = useUIState((state) => state.isEditingMessage); const setActivePromptFileBasePath = useUIState((state) => state.setActivePromptFileBasePath); const { isEnabled: ttsEnabled } = useTTS(); @@ -192,12 +210,12 @@ export function SessionDetail() { const workspaceBasePath = (isAssistantSession ? assistantFileBasePath : repo?.localPath) ?? repo?.localPath; useEffect(() => { - setActivePromptFileBasePath(repoDirectory ? workspaceBasePath ?? null : null) + setActivePromptFileBasePath(sessionDirectory ? workspaceBasePath ?? null : null) return () => { setActivePromptFileBasePath(null) } - }, [repoDirectory, setActivePromptFileBasePath, workspaceBasePath]) + }, [sessionDirectory, setActivePromptFileBasePath, workspaceBasePath]) useAutoPlayLastResponse({ sessionId: sessionId ?? '', @@ -224,20 +242,20 @@ export function SessionDetail() { }, [sessionId, minimizedQuestion]) const syncPendingActionsForSession = useCallback(async () => { - if (!repoDirectory || !sessionId) return + if (!sessionDirectory || !sessionId) return await Promise.all([ - syncPermissionsForSession(repoDirectory, sessionId), - syncQuestionsForSession(repoDirectory, sessionId), + syncPermissionsForSession(sessionDirectory, sessionId), + syncQuestionsForSession(sessionDirectory, sessionId), ]) - }, [repoDirectory, sessionId, syncPermissionsForSession, syncQuestionsForSession]) + }, [sessionDirectory, sessionId, syncPermissionsForSession, syncQuestionsForSession]) useQuery({ - queryKey: ['opencode', 'pending-actions', opcodeUrl, sessionId, repoDirectory], + queryKey: ['opencode', 'pending-actions', opcodeUrl, sessionId, sessionDirectory], queryFn: async () => { await syncPendingActionsForSession() return null }, - enabled: !!repoDirectory && !!sessionId, + enabled: !!sessionDirectory && !!sessionId, refetchOnMount: 'always', refetchOnReconnect: true, refetchOnWindowFocus: true, @@ -270,37 +288,37 @@ export function SessionDetail() { showToast.loading('Compacting session...', { id: `compact-${sessionId}` }); try { - const client = createOpenCodeClient(opcodeUrl, repoDirectory); + const client = createOpenCodeClient(opcodeUrl, sessionDirectory); await client.summarizeSession(sessionId, model.providerID, model.modelID); } catch (error) { showToast.error(`Compact failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [opcodeUrl, sessionId, model, repoDirectory]); + }, [opcodeUrl, sessionId, model, sessionDirectory]); const handleUndo = useCallback(async () => { if (!opcodeUrl || !sessionId) return; try { - const client = createOpenCodeClient(opcodeUrl, repoDirectory); + const client = createOpenCodeClient(opcodeUrl, sessionDirectory); await client.sendCommand(sessionId, { command: 'undo', arguments: '' }); } catch (error) { showToast.error(`Undo failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [opcodeUrl, sessionId, repoDirectory]); + }, [opcodeUrl, sessionId, sessionDirectory]); const handleRedo = useCallback(async () => { if (!opcodeUrl || !sessionId) return; try { - const client = createOpenCodeClient(opcodeUrl, repoDirectory); + const client = createOpenCodeClient(opcodeUrl, sessionDirectory); await client.sendCommand(sessionId, { command: 'redo', arguments: '' }); } catch (error) { showToast.error(`Redo failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [opcodeUrl, sessionId, repoDirectory]); + }, [opcodeUrl, sessionId, sessionDirectory]); const handleFork = useCallback(async () => { if (!opcodeUrl || !sessionId) return; try { - const client = createOpenCodeClient(opcodeUrl, repoDirectory); + const client = createOpenCodeClient(opcodeUrl, sessionDirectory); const forkedSession = await client.forkSession(sessionId); if (forkedSession?.id) { navigate(`/repos/${repoId}/sessions/${forkedSession.id}${sessionRouteSuffix}`); @@ -309,7 +327,7 @@ export function SessionDetail() { } catch (error) { showToast.error(`Fork failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [opcodeUrl, sessionId, repoDirectory, navigate, repoId, sessionRouteSuffix]); + }, [opcodeUrl, sessionId, sessionDirectory, navigate, repoId, sessionRouteSuffix]); const handleCloseSession = useCallback(() => { const tab = new URLSearchParams(location.search).get('repoTab') ?? undefined; @@ -497,7 +515,7 @@ export function SessionDetail() { @@ -514,11 +532,11 @@ export function SessionDetail() {
{repoLoading || sessionLoading || messagesLoading ? ( - ) : opcodeUrl && repoDirectory ? ( + ) : opcodeUrl && sessionDirectory ? ( ) : null}
- {opcodeUrl && repoDirectory && !isEditingMessage && ( + {opcodeUrl && sessionDirectory && !isEditingMessage && (
{ }) }) + it('subscribes and caches using the session directory when it differs from the repo path', async () => { + mocks.useSession.mockReturnValue({ + data: { id: 'sess-wt-1', directory: '/abs/worktrees/job-1-run-1', title: 'Scheduled run', time: {} }, + isLoading: false, + }) + + renderAssistantSession('sess-wt-1') + + await waitFor(() => { + expect(mocks.useSSE.mock.calls.at(-1)?.[1]).toBe('/abs/worktrees/job-1-run-1') + }) + + expect(mocks.useMessages.mock.calls.at(-1)?.[2]).toBe('/abs/worktrees/job-1-run-1') + }) + + it('keeps using the repo path when the session lives in the repo directory', async () => { + mocks.useSession.mockReturnValue({ + data: { id: 'sess-asst-1', directory: '/abs/assistant', title: 'Assistant chat', time: {} }, + isLoading: false, + }) + + renderAssistantSession('sess-asst-1') + + await waitFor(() => { + expect(mocks.useSSE.mock.calls.at(-1)?.[1]).toBe('/abs/assistant') + }) + + expect(mocks.useMessages.mock.calls.at(-1)?.[2]).toBe('/abs/assistant') + }) + it('shows the loading state for a non-assistant session whose repo has not loaded', async () => { mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index d29761b6..e9c99bfe 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -31,6 +31,7 @@ export const DEFAULT_DESTRUCTIVE_BASH_PATTERNS = [ export const SchedulePermissionConfigSchema = z.object({ allowExternalDirectory: z.boolean().default(false), + allowQuestions: z.boolean().default(false), bashDenyPatterns: z.array(z.string().min(1).max(200)).max(200) .default([...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]), }) @@ -58,8 +59,9 @@ export type SchedulePermissionRuleset = SchedulePermissionRule[] * `{ permission, pattern, action }` rules (`PermissionV1.Ruleset`), evaluated * with last-match-wins semantics (see https://opencode.ai/docs/permissions). * A leading `*`/`*` allow rule sets the allow-all baseline; the trailing - * `external_directory` and `bash` deny rules then override it for external - * directory access and matching destructive command patterns. + * `external_directory`, `question` and `bash` deny rules then override it for + * external directory access, agent questions that would block an unattended run + * with nobody to answer them, and matching destructive command patterns. */ export function buildSchedulePermissionRuleset( config: SchedulePermissionConfig | null | undefined, @@ -69,6 +71,9 @@ export function buildSchedulePermissionRuleset( if (!cfg.allowExternalDirectory) { ruleset.push({ permission: 'external_directory', pattern: '*', action: 'deny' }) } + if (!cfg.allowQuestions) { + ruleset.push({ permission: 'question', pattern: '*', action: 'deny' }) + } for (const pattern of cfg.bashDenyPatterns) { ruleset.push({ permission: 'bash', pattern, action: 'deny' }) }