From a75d86c339b6884aaa00a716ea222de93e67ff16 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:39:51 +0000 Subject: [PATCH 1/3] fix: stabilize scheduled runs and background task tracking --- backend/src/services/opencode-models.ts | 231 +++++- backend/src/services/schedules.ts | 224 +++++- backend/test/services/opencode-models.test.ts | 354 ++++++++-- backend/test/services/schedules.test.ts | 660 +++++++++++++++++- docs/features/schedules.md | 4 +- .../src/components/message/MessagePart.tsx | 4 +- .../components/message/MessageThread.test.tsx | 15 +- .../src/components/message/MessageThread.tsx | 2 + .../components/message/ToolCallPart.test.tsx | 93 ++- .../src/components/message/ToolCallPart.tsx | 76 +- .../session/BackgroundWorkBar.test.tsx | 328 ++++++++- .../components/session/BackgroundWorkBar.tsx | 173 ++++- frontend/src/contexts/EventContext.test.tsx | 127 +++- frontend/src/contexts/EventContext.tsx | 20 +- frontend/src/hooks/useOpenCode.test.tsx | 94 ++- frontend/src/hooks/useOpenCode.ts | 23 +- frontend/src/hooks/useSSE.test.tsx | 141 +++- frontend/src/hooks/useSSE.ts | 46 +- frontend/src/hooks/useSessionShells.ts | 52 +- frontend/src/lib/backgroundWork.test.ts | 248 +++++++ frontend/src/lib/backgroundWork.ts | 308 ++++++++ frontend/src/lib/queryInvalidation.ts | 8 + frontend/src/pages/SessionDetail.tsx | 1 + .../src/stores/sessionStatusStore.test.ts | 166 ++++- frontend/src/stores/sessionStatusStore.ts | 149 +++- 25 files changed, 3328 insertions(+), 219 deletions(-) create mode 100644 frontend/src/lib/backgroundWork.test.ts create mode 100644 frontend/src/lib/backgroundWork.ts diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index dbcced86f..a6fd022d3 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -1,4 +1,4 @@ -import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' +import type { ConfigEntry, ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from './opencode/client' @@ -9,6 +9,16 @@ export interface ResolvedOpenCodeModel { model: string } +export interface ResolveOpenCodeModelOptions { + preferredModel?: string | null + signal?: AbortSignal +} + +export const MODEL_LOAD_TIMEOUT_MS = 15_000 +export const MODEL_LOAD_POLL_MS = 500 + +type ConfigDocumentModel = Extract['info']['model'] + function normalizeModelCandidate(model: string | null | undefined): string | null { if (!model) { return null @@ -31,29 +41,24 @@ function findAvailable(models: ModelInfo[], ref: ModelRef): ModelInfo | undefine return models.find((model) => model.providerID === ref.providerID && model.id === ref.id) } -export async function resolveOpenCodeModel( - client: OpenCodeClient, - directory: string, - options?: { - preferredModel?: string | null - }, -): Promise { - const location = openCodeLocation(directory) - const [modelsResponse, defaultResponse] = await Promise.all([ - client.api.model.list(location), - client.api.model.default(location), - ]) - const models = modelsResponse.data +function isTargetLoaded(models: ModelInfo[], targetRef: ModelRef | undefined): boolean { + return !targetRef || findAvailable(models, targetRef) !== undefined +} - const preferred = normalizeModelCandidate(options?.preferredModel) - if (preferred) { - const parsedPreferred = parseOpenCodeModelRef(preferred) - if (parsedPreferred && findAvailable(models, parsedPreferred)) { - return toResolvedModel(parsedPreferred) - } +function resolveFromLoadedModels( + models: ModelInfo[], + defaultModel: ModelInfo | null, + configuredRef: ModelRef | undefined, + preferredRef: ModelRef | undefined, +): ResolvedOpenCodeModel | null { + if (preferredRef && findAvailable(models, preferredRef)) { + return toResolvedModel(preferredRef) + } + + if (configuredRef && findAvailable(models, configuredRef)) { + return toResolvedModel(configuredRef) } - const defaultModel = defaultResponse.data if (defaultModel) { const defaultRef: ModelRef = { providerID: defaultModel.providerID, id: defaultModel.id } if (findAvailable(models, defaultRef)) { @@ -62,9 +67,187 @@ export async function resolveOpenCodeModel( } const fallback = models.find((model) => model.enabled) - if (fallback) { - return toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) + return fallback ? toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) : null +} + +function toConfiguredRef(model: ConfigDocumentModel): ModelRef | undefined { + if (!model) { + return undefined + } + + if (typeof model === 'string') { + return parseOpenCodeModelRef(model) + } + + return { + providerID: model.providerID, + id: model.model, + ...(model.variant ? { variant: model.variant } : {}), + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + + function onAbort(): void { + clearTimeout(timer) + resolve() + } + + signal.addEventListener('abort', onAbort, { once: true }) + + if (signal.aborted) { + onAbort() + } + }) +} + +type PollingResult = { ok: true; value: T } | { ok: false } + +function isAbortLike(error: unknown, signal: AbortSignal): boolean { + if (error === signal.reason) { + return true + } + + if (typeof error !== 'object' || error === null) { + return false + } + + if ((error as { name?: unknown }).name === 'AbortError') { + return true } - throw new Error('No configured OpenCode models are available') + const cause = (error as { cause?: unknown }).cause + return cause !== undefined && cause !== error && isAbortLike(cause, signal) +} + +async function runPollingRequest( + request: () => Promise, + external: AbortSignal | undefined, + deadline: AbortSignal, +): Promise> { + try { + return { ok: true, value: await request() } + } catch (error) { + external?.throwIfAborted() + + if (deadline.aborted && isAbortLike(error, deadline)) { + return { ok: false } + } + + throw error + } +} + +async function readConfiguredRef( + client: OpenCodeClient, + location: ReturnType, + signal: AbortSignal, +): Promise { + const entries = await client.api.config.get(location, { signal }) + const model = entries.reduce( + (current, entry) => (entry.type === 'document' && entry.info.model ? entry.info.model : current), + undefined, + ) + return toConfiguredRef(model) +} + +export async function resolveOpenCodeModel( + client: OpenCodeClient, + directory: string, + options?: ResolveOpenCodeModelOptions, +): Promise { + const location = openCodeLocation(directory) + const preferred = normalizeModelCandidate(options?.preferredModel) + const preferredRef = preferred ? parseOpenCodeModelRef(preferred) : undefined + const external = options?.signal + + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => { + timeoutController.abort(new Error('Timed out waiting for the OpenCode model catalog to load')) + }, MODEL_LOAD_TIMEOUT_MS) + const signal = external ? AbortSignal.any([external, timeoutController.signal]) : timeoutController.signal + + let models: ModelInfo[] = [] + let defaultModel: ModelInfo | null = null + + try { + external?.throwIfAborted() + + const configuredRef = await readConfiguredRef(client, location, signal) + const targetRef = preferredRef ?? configuredRef + + for (;;) { + external?.throwIfAborted() + + if (timeoutController.signal.aborted) { + break + } + + const listResult = await runPollingRequest( + () => client.api.model.list(location, { signal }), + external, + timeoutController.signal, + ) + if (!listResult.ok) { + break + } + models = listResult.value.data + + external?.throwIfAborted() + + if (timeoutController.signal.aborted) { + break + } + + const defaultResult = await runPollingRequest( + () => client.api.model.default(location, { signal }), + external, + timeoutController.signal, + ) + if (!defaultResult.ok) { + break + } + defaultModel = defaultResult.value.data + + external?.throwIfAborted() + + if (timeoutController.signal.aborted) { + break + } + + const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef) + if (resolved && isTargetLoaded(models, targetRef)) { + return resolved + } + + await sleep(MODEL_LOAD_POLL_MS, signal) + } + + external?.throwIfAborted() + + const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef) + if (resolved) { + return resolved + } + + if (models.length === 0) { + throw timeoutController.signal.reason instanceof Error + ? timeoutController.signal.reason + : new Error('Timed out waiting for the OpenCode model catalog to load') + } + + throw new Error('No configured OpenCode models are available') + } finally { + clearTimeout(timeoutId) + } } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 8b96a4890..b0ef82b70 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -73,6 +73,20 @@ interface SessionMonitor { dispose(): void } +interface ActiveRun { + runId: number + abort: AbortController +} + +type CancelDecision = 'cancelled' | 'settled' | 'resume' + +interface PendingCancel { + runId: number + decision: Promise + resolve: (decision: CancelDecision) => void + resolved: boolean +} + type AssistantOutcome = | { kind: 'busy' } | { kind: 'settled'; responseText: string | null; errorText: string | null } @@ -248,7 +262,8 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni } export class ScheduleService { - private static activeRuns = new Set() + private static activeRuns = new Map() + private static pendingCancels = new Map() private static activeTeardowns = new Set() private onJobChange: ((job: ScheduleJob | null, jobId: number) => void) | null = null @@ -262,6 +277,70 @@ export class ScheduleService { this.onJobChange = handler } + private static abortActiveRun(jobId: number, runId: number): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId === runId) { + active.abort.abort() + } + } + + private static requestCancel(jobId: number, runId: number): Promise { + const existing = ScheduleService.pendingCancels.get(jobId) + + if (existing?.runId === runId && !existing.resolved) { + throw new ScheduleServiceError('Schedule run cancellation is already in progress', 409) + } + + if (existing) { + existing.resolved = true + existing.resolve('resume') + } + + let resolve!: (decision: CancelDecision) => void + const decision = new Promise((r) => { resolve = r }) + ScheduleService.pendingCancels.set(jobId, { runId, decision, resolve, resolved: false }) + return decision + } + + private static settleCancel(jobId: number, runId: number, status: ScheduleRun['status'] | undefined): void { + const pending = ScheduleService.pendingCancels.get(jobId) + if (pending?.runId !== runId || pending.resolved) { + return + } + + pending.resolved = true + pending.resolve(status === 'cancelled' ? 'cancelled' : status === 'completed' || status === 'failed' ? 'settled' : 'resume') + } + + private static resolveCancel(jobId: number, runId: number, decision: CancelDecision): void { + const pending = ScheduleService.pendingCancels.get(jobId) + if (pending?.runId !== runId || pending.resolved) { + return + } + + pending.resolved = true + pending.resolve(decision) + } + + private static awaitCancelDecision(jobId: number, runId: number): Promise { + const pending = ScheduleService.pendingCancels.get(jobId) + return pending?.runId === runId ? pending.decision : Promise.resolve('resume') + } + + private static clearCancel(jobId: number, runId: number): void { + if (ScheduleService.pendingCancels.get(jobId)?.runId !== runId) { + return + } + + ScheduleService.pendingCancels.delete(jobId) + } + + private static releaseActiveRun(jobId: number, runId: number): void { + if (ScheduleService.activeRuns.get(jobId)?.runId === runId) { + ScheduleService.activeRuns.delete(jobId) + } + } + getActiveRunSessions(): ScheduledSessionRef[] { const refs: ScheduledSessionRef[] = [] @@ -312,7 +391,7 @@ export class ScheduleService { continue } - ScheduleService.activeRuns.add(job.id) + ScheduleService.activeRuns.set(job.id, { runId: run.id, abort: new AbortController() }) await this.recoverRunningRun(job, run) } } @@ -475,8 +554,6 @@ export class ScheduleService { throw new ScheduleServiceError('Schedule is already running', 409) } - ScheduleService.activeRuns.add(jobId) - const startedAt = Date.now() const run = createScheduleRun(this.db, { jobId, @@ -487,6 +564,9 @@ export class ScheduleService { createdAt: startedAt, }) + const abort = new AbortController() + ScheduleService.activeRuns.set(jobId, { runId: run.id, abort }) + try { // Prepare worktree for isolated runs const wt = await this.worktreeManager.prepare(repo, job, run.id) @@ -498,10 +578,24 @@ export class ScheduleService { }) } + if (abort.signal.aborted) { + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + return this.loadRun(repoId, jobId, run.id, run) + } + const runJob = { ...job, agentSlug: await this.resolveRunAgent(runDirectory, job.agentSlug) } const model = await resolveOpenCodeModel(this.openCodeClient, runDirectory, { preferredModel: job.model, + signal: abort.signal, }) + + if (abort.signal.aborted) { + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + return this.loadRun(repoId, jobId, run.id, run) + } + const sessionTitle = buildSessionTitle(job) let session: { id: string } try { @@ -513,9 +607,21 @@ export class ScheduleService { permissions: buildSchedulePermissionRuleset(job.permissionConfig), }) } catch (error) { + if (abort.signal.aborted) { + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + return this.loadRun(repoId, jobId, run.id, run) + } throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } + if (abort.signal.aborted) { + await this.interruptCancelledSession(session.id) + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + return this.loadRun(repoId, jobId, run.id, run) + } + const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { sessionId: session.id, sessionTitle, @@ -546,6 +652,13 @@ export class ScheduleService { return runWithSession } catch (error) { + if (abort.signal.aborted) { + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + ScheduleService.releaseActiveRun(jobId, run.id) + return this.loadRun(repoId, jobId, run.id, run) + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to run schedule ${jobId}:`, error) @@ -574,17 +687,16 @@ export class ScheduleService { // Teardown worktree if one was created before the error await this.teardownWorktree(repoId, jobId, run.id, job, repo) + ScheduleService.releaseActiveRun(jobId, run.id) + if (!failedRun) { - ScheduleService.activeRuns.delete(jobId) throw new ScheduleServiceError('Failed to load failed run', 500) } if (error instanceof ScheduleServiceError) { - ScheduleService.activeRuns.delete(jobId) throw error } - ScheduleService.activeRuns.delete(jobId) throw new ScheduleServiceError(errorText, 500) } } @@ -598,8 +710,20 @@ export class ScheduleService { throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) } - if (run.sessionId) { - const outcome = await this.readAssistantOutcome(run.sessionId) + if (!run.sessionId) { + ScheduleService.abortActiveRun(jobId, runId) + return this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + } + + ScheduleService.requestCancel(jobId, runId) + + try { + let outcome: AssistantOutcome + try { + outcome = await this.readAssistantOutcome(run.sessionId) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) + } if (outcome.kind === 'settled') { await this.finalizeRecoveredRun(job, run, { @@ -607,7 +731,7 @@ export class ScheduleService { responseText: outcome.responseText, errorText: outcome.errorText, }, repo) - + ScheduleService.resolveCancel(jobId, runId, 'settled') return this.getRun(repoId, jobId, runId) } @@ -616,8 +740,33 @@ export class ScheduleService { } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } + + return await this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + } catch (error) { + this.settleCancelFromState(repoId, jobId, runId) + throw error + } + } + + private settleCancelFromState(repoId: number, jobId: number, runId: number): void { + let status: ScheduleRun['status'] | undefined + try { + status = getScheduleRunById(this.db, repoId, jobId, runId)?.status + } catch { + status = undefined } + ScheduleService.settleCancel(jobId, runId, status) + } + + private async finalizeCancelledRun( + repo: Repo, + job: ScheduleJob, + run: ScheduleRun, + repoId: number, + jobId: number, + runId: number, + ): Promise { const finishedAt = Date.now() const cancellationMessage = 'Run cancelled by user.' const cancelledRun = updateScheduleRun(this.db, repoId, jobId, runId, { @@ -644,7 +793,8 @@ export class ScheduleService { }) await this.teardownWorktree(repoId, jobId, runId, job, repo) - ScheduleService.activeRuns.delete(jobId) + ScheduleService.releaseActiveRun(jobId, runId) + ScheduleService.resolveCancel(jobId, runId, 'cancelled') if (!cancelledRun) { throw new ScheduleServiceError('Failed to update cancelled run', 500) @@ -666,7 +816,16 @@ export class ScheduleService { const repo = this.assertRepo(input.repoId) try { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const skills = await this.resolveSkillAttachments(input.directory, input.job.skillMetadata) + + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const notes = input.job.skillMetadata?.notes?.trim() await this.openCodeClient.api.session.prompt({ sessionID: input.sessionId, @@ -674,6 +833,10 @@ export class ScheduleService { ...(skills.length > 0 ? { skills } : {}), }) + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + input.sessionMonitor.markSubmitted() await this.monitorRunCompletion({ @@ -687,6 +850,10 @@ export class ScheduleService { }) return } catch (error) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to submit prompt for schedule ${input.job.id}:`, error) @@ -717,9 +884,11 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } finally { + await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, repo) - ScheduleService.activeRuns.delete(input.job.id) + ScheduleService.releaseActiveRun(input.job.id, input.runId) + ScheduleService.clearCancel(input.job.id, input.runId) } } @@ -736,6 +905,10 @@ export class ScheduleService { const repo = this.assertRepo(input.repoId) const currentAssistantState = await this.readSettledAssistantState(input.sessionId) if (currentAssistantState) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + await this.finalizeRecoveredRun(input.job, { id: input.runId, repoId: input.repoId, @@ -752,6 +925,11 @@ export class ScheduleService { } const response = await this.waitForAssistantMessage(input.sessionId, input.sessionMonitor) + + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) if (!currentRun || currentRun.status !== 'running') { return @@ -800,6 +978,10 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } catch (error) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to monitor schedule ${input.job.id}:`, error) @@ -830,9 +1012,11 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } finally { + await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, this.assertRepo(input.repoId)) - ScheduleService.activeRuns.delete(input.job.id) + ScheduleService.releaseActiveRun(input.job.id, input.runId) + ScheduleService.clearCancel(input.job.id, input.runId) } } @@ -925,7 +1109,19 @@ export class ScheduleService { }) await this.teardownWorktree(run.repoId, run.jobId, run.id, job, repo) - ScheduleService.activeRuns.delete(job.id) + ScheduleService.releaseActiveRun(job.id, run.id) + } + + private async interruptCancelledSession(sessionId: string): Promise { + try { + await this.openCodeClient.api.session.interrupt({ sessionID: sessionId }) + } catch (error) { + logger.error(`Failed to interrupt cancelled schedule session ${sessionId}:`, error) + } + } + + private loadRun(repoId: number, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { + return getScheduleRunById(this.db, repoId, jobId, runId) ?? fallback } private async teardownWorktree(repoId: number, jobId: number, runId: number, job: ScheduleJob, repo: Repo): Promise { diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts index 1067be251..a0a7313df 100644 --- a/backend/test/services/opencode-models.test.ts +++ b/backend/test/services/opencode-models.test.ts @@ -1,79 +1,104 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ModelInfo } from '@opencode-manager/shared/opencode' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConfigEntry, ModelInfo } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' -import { resolveOpenCodeModel } from '../../src/services/opencode-models' +import { + MODEL_LOAD_POLL_MS, + MODEL_LOAD_TIMEOUT_MS, + resolveOpenCodeModel, +} from '../../src/services/opencode-models' + +const DIR = '/workspace/repos/sample-project' +const LOCATION = { directory: DIR } function model(providerID: string, id: string, enabled: boolean = true): ModelInfo { return { providerID, id, enabled } as ModelInfo } +function configEntry(model: string): ConfigEntry { + return { type: 'document', info: { model } } as ConfigEntry +} + function createClientStub(input: { models: ModelInfo[] defaultModel?: ModelInfo | null + configEntries?: ConfigEntry[] listError?: Error defaultError?: Error + configError?: Error }): OpenCodeClient { return { api: { model: { list: vi.fn(async () => { if (input.listError) throw input.listError - return { location: { directory: '/workspace/repos/sample-project' }, data: input.models } + return { location: LOCATION, data: input.models } }), default: vi.fn(async () => { if (input.defaultError) throw input.defaultError - return { location: { directory: '/workspace/repos/sample-project' }, data: input.defaultModel ?? null } + return { location: LOCATION, data: input.defaultModel ?? null } }), }, + config: { + get: vi.fn(async () => { + if (input.configError) throw input.configError + return input.configEntries ?? [] + }), + }, + }, + } as unknown as OpenCodeClient +} + +function createStalledListClient(): OpenCodeClient { + return { + api: { + model: { + list: vi.fn((_input: unknown, requestOptions?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = requestOptions?.signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + })), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { + get: vi.fn(async () => []), + }, }, } as unknown as OpenCodeClient } describe('resolveOpenCodeModel', () => { beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() }) + afterEach(() => { + vi.useRealTimers() + }) + it('returns the preferred model when it is available', async () => { const client = createClientStub({ models: [model('openai', 'gpt-5'), model('openai', 'gpt-5-mini')], defaultModel: model('openai', 'gpt-5-mini'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/gpt-5', - }) + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5' }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - model: 'openai/gpt-5', - }) + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) }) it('preserves the variant of a preferred model reference', async () => { - const client = createClientStub({ - models: [model('openai', 'gpt-5')], - }) + const client = createClientStub({ models: [model('openai', 'gpt-5')] }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/gpt-5#high', - }) + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5#high' }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - variant: 'high', - model: 'openai/gpt-5#high', - }) + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', variant: 'high', model: 'openai/gpt-5#high' }) }) it('keeps slashes inside the model id when parsing the preferred model', async () => { - const client = createClientStub({ - models: [model('openrouter', 'anthropic/claude-sonnet-4')], - }) + const client = createClientStub({ models: [model('openrouter', 'anthropic/claude-sonnet-4')] }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openrouter/anthropic/claude-sonnet-4', }) @@ -90,15 +115,14 @@ describe('resolveOpenCodeModel', () => { defaultModel: model('openai', 'gpt-5-mini'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/retired', - }) - - expect(result).toEqual({ + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/retired' }) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion }) it('falls back to the first enabled model when the default model is unavailable', async () => { @@ -107,15 +131,14 @@ describe('resolveOpenCodeModel', () => { defaultModel: model('openai', 'retired'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/retired', - }) - - expect(result).toEqual({ + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/retired' }) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion }) it('skips disabled models when choosing the first enabled entry', async () => { @@ -123,37 +146,262 @@ describe('resolveOpenCodeModel', () => { models: [model('openai', 'gpt-5', false), model('anthropic', 'claude-sonnet-4')], }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project') + const result = await resolveOpenCodeModel(client, DIR) - expect(result).toEqual({ + expect(result).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4', model: 'anthropic/claude-sonnet-4' }) + }) + + it('throws when no enabled models are available', async () => { + const client = createClientStub({ models: [model('openai', 'gpt-5', false)] }) + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).rejects.toThrow('No configured OpenCode models are available') + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('waits for the preferred model while the server loads the directory catalog', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + return { + location: LOCATION, + data: listCalls === 1 + ? [model('opencode', 'longcat-2.5-preview-free')] + : [model('opencode', 'longcat-2.5-preview-free'), model('GreatScott', 'deepseek-v4.1-flash')], + } + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'GreatScott/deepseek-v4.1-flash' }) + const assertion = expect(promise).resolves.toEqual({ + providerID: 'GreatScott', + id: 'deepseek-v4.1-flash', + model: 'GreatScott/deepseek-v4.1-flash', + }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS) + await assertion + expect(listCalls).toBe(2) + }) + + it('awaits the configured model while an empty then partial catalog loads', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + const catalogs = [ + [] as ModelInfo[], + [model('opencode', 'longcat-2.5-preview-free')], + [model('opencode', 'longcat-2.5-preview-free'), model('GreatScott', 'deepseek-v4.1-flash')], + ] + return { location: LOCATION, data: catalogs[Math.min(listCalls, catalogs.length) - 1] } + }), + default: vi.fn(async () => ({ + location: LOCATION, + data: model('opencode', 'longcat-2.5-preview-free'), + })), + }, + config: { get: vi.fn(async () => [configEntry('GreatScott/deepseek-v4.1-flash')]) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ + providerID: 'GreatScott', + id: 'deepseek-v4.1-flash', + model: 'GreatScott/deepseek-v4.1-flash', + }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS * 2) + await assertion + expect(listCalls).toBe(3) + }) + + it('does not resolve a same-provider fallback before the target model loads', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-4')], + defaultModel: model('openai', 'gpt-4'), + }) + + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5' }) + let settled = false + void promise.then(() => { settled = true }, () => { settled = true }) + + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-4', model: 'openai/gpt-4' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('falls back to the default when the configured target never loads', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), + configEntries: [configEntry('missing/model')], + }) + + const promise = resolveOpenCodeModel(client, DIR) + let settled = false + void promise.then(() => { settled = true }, () => { settled = true }) + + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('waits for the catalog to populate when nothing is preferred or configured', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + return { + location: LOCATION, + data: listCalls === 1 ? [] : [model('anthropic', 'claude-sonnet-4')], + } + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4', model: 'anthropic/claude-sonnet-4', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS) + await assertion }) - it('throws when no enabled models are available', async () => { - const client = createClientStub({ models: [model('openai', 'gpt-5', false)] }) + it('falls back to the cached catalog when a later poll stalls until the deadline', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn((_input: unknown, requestOptions?: { signal?: AbortSignal }) => { + listCalls += 1 + if (listCalls === 1) { + return Promise.resolve({ location: LOCATION, data: [model('openai', 'gpt-5')] }) + } + return new Promise((_resolve, reject) => { + const signal = requestOptions?.signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => [configEntry('missing/model')]) }, + }, + } as unknown as OpenCodeClient - await expect(resolveOpenCodeModel(client, '/workspace/repos/sample-project')).rejects.toThrow( - 'No configured OpenCode models are available', + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + expect(listCalls).toBe(2) + }) + + it('times out an abortable stalled catalog request', async () => { + const client = createStalledListClient() + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).rejects.toThrow( + 'Timed out waiting for the OpenCode model catalog to load', ) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + 1) + await assertion }) - it('ignores a malformed preferred model reference and falls back to the default', async () => { + it('rejects when the external signal aborts a stalled request', async () => { + const client = createStalledListClient() + const controller = new AbortController() + + const promise = resolveOpenCodeModel(client, DIR, { signal: controller.signal }) + const assertion = expect(promise).rejects.toThrow('cancelled') + await vi.advanceTimersByTimeAsync(0) + controller.abort(new Error('cancelled')) + await vi.advanceTimersByTimeAsync(0) + await assertion + }) + + it('rejects immediately when the external signal is already aborted', async () => { + const client = createClientStub({ models: [model('openai', 'gpt-5')] }) + const controller = new AbortController() + controller.abort(new Error('cancelled')) + + await expect(resolveOpenCodeModel(client, DIR, { signal: controller.signal })).rejects.toThrow('cancelled') + }) + + it('rejects when the external signal aborts as the default model request fulfills', async () => { + const controller = new AbortController() + const client = { + api: { + model: { + list: vi.fn(async () => ({ location: LOCATION, data: [model('openai', 'gpt-5')] })), + default: vi.fn(async () => { + controller.abort(new Error('cancelled')) + return { location: LOCATION, data: model('openai', 'gpt-5') } + }), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + await expect( + resolveOpenCodeModel(client, DIR, { signal: controller.signal }), + ).rejects.toThrow('cancelled') + }) + + it('propagates a catalog list error', async () => { + const client = createClientStub({ models: [], listError: new Error('list failed') }) + + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('list failed') + }) + + it('propagates a default model error', async () => { const client = createClientStub({ models: [model('openai', 'gpt-5')], - defaultModel: model('openai', 'gpt-5'), + defaultError: new Error('default failed'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai', + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('default failed') + }) + + it('propagates a configured model read error', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), + configError: new Error('config failed'), }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - model: 'openai/gpt-5', + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('config failed') + }) + + it('ignores a malformed preferred model reference and falls back to the default', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), }) + + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai' }) + + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) }) }) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 698f57b14..574a4bf4d 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' @@ -175,6 +175,7 @@ describe('ScheduleService', () => { vi.clearAllMocks() Reflect.get(ScheduleService, 'activeRuns').clear() Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) @@ -314,7 +315,7 @@ describe('ScheduleService', () => { expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith( expect.anything(), repo.fullPath, - { preferredModel: 'openai/retired' }, + { preferredModel: 'openai/retired', signal: expect.any(AbortSignal) }, ) }) @@ -875,7 +876,7 @@ describe('ScheduleService', () => { it('blocks deleteJob when a running run exists in activeRuns', () => { const stub = createStubScheduleApi() const service = makeService(stub.api) - Reflect.get(ScheduleService, 'activeRuns').add(7) + Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) expect(() => service.deleteJob(42, 7)).toThrow('Cannot delete a schedule while it is running. Cancel the run first.') }) @@ -894,7 +895,7 @@ describe('ScheduleService', () => { const onJobChange = vi.fn() service.setJobChangeHandler(onJobChange) mocks.listScheduleJobIdsByRepo.mockReturnValue([7, 8]) - Reflect.get(ScheduleService, 'activeRuns').add(7) + Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) expect(() => service.prepareRepoDelete(42)).toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') expect(onJobChange).not.toHaveBeenCalled() @@ -1106,6 +1107,657 @@ describe('ScheduleService', () => { }) }) +describe('ScheduleService startup cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + + mocks.getRepoById.mockReturnValue(repo) + mocks.getScheduleJobById.mockReturnValue(job) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.createScheduleRun.mockReturnValue(baseRun) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + mocks.onEvent.mockReturnValue(vi.fn()) + mocks.stubWorktreeManager.prepare.mockResolvedValue(null) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) + }) + + afterEach(() => { + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + }) + + it('stops a startup cancelled while the model is resolving without creating or prompting a session', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + + let resolveModel!: (value: { providerID: string; id: string; model: string }) => void + mocks.resolveOpenCodeModel.mockReturnValueOnce(new Promise((resolve) => { resolveModel = resolve })) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.resolveOpenCodeModel).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolveModel({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + await runPromise + + expect(stub.api.session.create).not.toHaveBeenCalled() + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('interrupts a session created concurrently with cancellation and never prompts it', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-cancel-race', messages: [] }) + const service = makeService(stub.api) + + let resolveCreate!: (value: { id: string }) => void + const createDeferred = new Promise<{ id: string }>((resolve) => { resolveCreate = resolve }) + vi.mocked(stub.api.session.create).mockReturnValueOnce(createDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.create).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolveCreate({ id: 'ses-cancel-race' }) + await runPromise + + expect(stub.api.session.interrupt).toHaveBeenCalledWith({ sessionID: 'ses-cancel-race' }) + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRunMetadata).not.toHaveBeenCalled() + }) + + it('tears down a worktree prepared after cancellation instead of leaking it', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + const worktreePath = '/workspace/worktrees/job-7-run-5' + const runBranch = 'schedule/7/run-5' + + let currentRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null, worktreePath: null, runBranch: null } + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRunWorktree.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleRun.mockReturnValue({ + ...currentRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + }) + + let resolvePrepare!: (value: { directory: string; worktreePath: string; runBranch: string }) => void + mocks.stubWorktreeManager.prepare.mockReturnValueOnce(new Promise((resolve) => { resolvePrepare = resolve })) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: 'abc123' }) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.stubWorktreeManager.prepare).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolvePrepare({ directory: worktreePath, worktreePath, runBranch }) + await runPromise + + expect(mocks.stubWorktreeManager.finalize).toHaveBeenCalledWith( + repo, + job, + expect.objectContaining({ id: 5, worktreePath }), + ) + expect(mocks.updateScheduleRunWorktree).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ worktreePath: null, commitHash: 'abc123' }), + ) + expect(stub.api.session.create).not.toHaveBeenCalled() + }) + + it('does not release the guard of a newer run when an older startup finishes cancelling', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + + let resolveModel!: (value: { providerID: string; id: string; model: string }) => void + mocks.resolveOpenCodeModel.mockReturnValueOnce(new Promise((resolve) => { resolveModel = resolve })) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.resolveOpenCodeModel).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + const activeRuns = Reflect.get(ScheduleService, 'activeRuns') as Map + activeRuns.set(7, { runId: 6, abort: new AbortController() }) + + resolveModel({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + await runPromise + + expect(activeRuns.get(7)?.runId).toBe(6) + }) + + it('skips the prompt when cancellation lands while skills are loading', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-skill-cancel', skills: [skill('git-release')], messages: [] }) + const service = makeService(stub.api) + const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: undefined } } + mocks.getScheduleJobById.mockReturnValue(jobWithSkills) + + let resolveSkillList!: (value: unknown) => void + vi.mocked(stub.api.skill.list).mockReturnValueOnce(new Promise((resolve) => { resolveSkillList = resolve }) as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-skill-cancel', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRunMetadata.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.skill.list).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + resolveSkillList({ location: { directory: '' }, data: [skill('git-release')] }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not mark a cancelled run failed when the prompt rejects after cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-prompt-abort', messages: [] }) + const service = makeService(stub.api) + + let rejectPrompt!: (error: Error) => void + const promptDeferred = new Promise((_resolve, reject) => { rejectPrompt = reject }) + vi.mocked(stub.api.session.prompt).mockReturnValueOnce(promptDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-prompt-abort', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.prompt).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + rejectPrompt(new Error('Prompt aborted')) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not start monitoring when cancellation lands while the prompt is in flight', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-prompt-monitor', messages: [] }) + const service = makeService(stub.api) + + let resolvePrompt!: (value: unknown) => void + const promptDeferred = new Promise((resolve) => { resolvePrompt = resolve }) + vi.mocked(stub.api.session.prompt).mockReturnValueOnce(promptDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-prompt-monitor', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.prompt).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + resolvePrompt({}) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(stub.api.session.prompt).toHaveBeenCalledTimes(1) + expect(stub.api.session.active).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) +}) + +describe('ScheduleService monitor cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + + mocks.getRepoById.mockReturnValue(repo) + mocks.getScheduleJobById.mockReturnValue(job) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.createScheduleRun.mockReturnValue(baseRun) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + mocks.onEvent.mockReturnValue(vi.fn()) + mocks.stubWorktreeManager.prepare.mockResolvedValue(null) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) + }) + + afterEach(() => { + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + }) + + function setupRunningMonitor(sessionID: string) { + const stub = createStubScheduleApi({ sessionID, messages: [] }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: sessionID, + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + return { stub, service } + } + + it('does not overwrite cancelled with a completion that arrives after cancel', async () => { + const { stub, service } = setupRunningMonitor('ses-monitor-idle') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-monitor-idle')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not overwrite cancelled with a monitor error that arrives after cancel', async () => { + const { stub, service } = setupRunningMonitor('ses-monitor-error') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + stub.state.messageError = new Error('messages unavailable') + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-monitor-error')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not finalize a settled monitor after cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-monitor-settled', messages: [] }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-monitor-settled', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + let resolveActive!: (value: Record) => void + vi.mocked(stub.api.session.active).mockReturnValueOnce(new Promise((resolve) => { resolveActive = resolve }) as never) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(1)) + + await service.cancelRun(42, 7, 5) + + stub.state.messages = [assistantMessage('Completed before monitor resolved.', { completed: true })] + resolveActive({}) + + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('keeps monitoring when the interrupt fails during cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-interrupt-fail', messages: [], interruptError: new Error('Abort refused') }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-interrupt-fail', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'completed' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 502 }) + + stub.state.messages = [assistantMessage('Finished after failed cancel.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-interrupt-fail')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) + + it('holds monitor completion until a pending cancellation resolves', async () => { + const { stub, service } = setupRunningMonitor('ses-pending-monitor') + const worktreePath = '/workspace/worktrees/job-7-run-5' + const runBranch = 'schedule/7/run-5' + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-pending-monitor', + sessionTitle: 'Scheduled: Weekly engineering summary', + worktreePath, + runBranch, + } + let currentRun = runningRun + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRun.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleRunWorktree.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: 'abc123' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalled()) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-pending-monitor')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + expect(mocks.stubWorktreeManager.finalize).not.toHaveBeenCalled() + + resolveInterrupt({ interrupted: true }) + await cancelPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + expect(mocks.stubWorktreeManager.finalize).toHaveBeenCalledTimes(1) + }) + + it('holds monitor status writes until a pending cancellation read resolves', async () => { + const { stub, service } = setupRunningMonitor('ses-pending-read') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(1)) + + let resolveActive!: (value: Record) => void + vi.mocked(stub.api.session.active).mockReturnValueOnce(new Promise((resolve) => { resolveActive = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(2)) + + captureEventListener()(repo.fullPath, { + id: 'evt_pending_interrupted', + created: Date.now(), + type: 'session.execution.interrupted', + location: { directory: repo.fullPath }, + data: { sessionID: 'ses-pending-read' }, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + resolveActive({}) + await cancelPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('holds the prompt until a pending cancellation resolves', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-pending-skill', skills: [skill('git-release')], messages: [] }) + const service = makeService(stub.api) + const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: undefined } } + mocks.getScheduleJobById.mockReturnValue(jobWithSkills) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-pending-skill', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + let resolveSkillList!: (value: unknown) => void + vi.mocked(stub.api.skill.list).mockReturnValueOnce(new Promise((resolve) => { resolveSkillList = resolve }) as never) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.skill.list).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalled()) + + resolveSkillList({ location: { directory: '' }, data: [skill('git-release')] }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + + resolveInterrupt({ interrupted: true }) + await cancelPromise + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('holds a failed cancellation retry pending', async () => { + const { stub, service } = setupRunningMonitor('ses-cancel-retry') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + vi.mocked(stub.api.session.interrupt).mockRejectedValueOnce(new Error('Abort refused')) + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 502 }) + + let resolveRetry!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveRetry = resolve }) as never) + + const retryPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalledTimes(2)) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-cancel-retry')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + resolveRetry({ interrupted: true }) + await retryPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('rejects a duplicate cancellation while one is in progress', async () => { + const { stub, service } = setupRunningMonitor('ses-cancel-dup') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const firstCancel = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalledTimes(1)) + + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 409 }) + expect(stub.api.session.interrupt).toHaveBeenCalledTimes(1) + + resolveInterrupt({ interrupted: true }) + await firstCancel + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('settles the decision from persisted state when finalization throws', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-finalize-throw', messages: [] }) + const service = makeService(stub.api) + let currentRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-finalize-throw', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRun.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleJobRunState.mockImplementationOnce(() => { + throw new Error('db write failed') + }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await expect(service.cancelRun(42, 7, 5)).rejects.toThrow('db write failed') + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-finalize-throw')) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + await vi.waitFor(() => expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false)) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) +}) + describe('ScheduleService worktree isolation', () => { const worktreePath = '/workspace/worktrees/job-7-run-5' const runBranch = 'schedule/7/run-5' diff --git a/docs/features/schedules.md b/docs/features/schedules.md index 59aec7fd8..cd67894b4 100644 --- a/docs/features/schedules.md +++ b/docs/features/schedules.md @@ -14,6 +14,8 @@ Schedules make OpenCode Manager proactive instead of purely session-driven. Good Each run is stored with status, timestamps, logs, assistant output, and a linked session you can open and continue. +Cancelling during startup stops model loading and prevents later prompt submission. If session creation was already in flight, the returned session is interrupted rather than prompted. + ## Creating a Schedule 1. Open a repository @@ -65,7 +67,7 @@ Schedules can run with: - a custom agent slug - a specific model override when needed -If a requested model is no longer available, OpenCode Manager falls back to a valid configured model for that provider so the run can still start when possible. +OpenCode Manager allows up to 15 seconds for a fresh worktree's model catalog to load before treating a missing requested or configured model as unavailable. It prefers the schedule override, then the configured model, then OpenCode's default, then the first enabled model. A fallback can use a different provider. Model-catalog requests are bounded by that deadline. ## Skills diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 029582a1e..9b6975f27 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -9,11 +9,12 @@ type AssistantContentPart = SessionMessageAssistant['content'][number] interface MessagePartProps { part: AssistantContentPart messageID?: string + directory?: string onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } -export const MessagePart = memo(function MessagePart({ part, messageID, onFileClick, onChildSessionClick }: MessagePartProps) { +export const MessagePart = memo(function MessagePart({ part, messageID, directory, onFileClick, onChildSessionClick }: MessagePartProps) { const { preferences } = useSettings() const simpleChatMode = preferences?.simpleChatMode ?? false const showReasoning = preferences?.showReasoning ?? false @@ -40,6 +41,7 @@ export const MessagePart = memo(function MessagePart({ part, messageID, onFileCl diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 6b8ceb2a6..10c149e07 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactElement } from 'react' import { MessageThread } from './MessageThread' import { useUIState } from '@/stores/uiStateStore' import { applySessionEvent, emptySessionTranscript } from '@/lib/session-projection' @@ -47,6 +49,12 @@ vi.mock('@/hooks/useTTS', () => ({ }), })) +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + getSession: () => new Promise(() => {}), + listShells: () => Promise.resolve([]), +})) + interface MockSettingsReturn { preferences: { simpleChatMode: boolean @@ -63,6 +71,11 @@ const setupSettings = (preferences: MockSettingsReturn['preferences']) => { }) } +const render = (ui: ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return rtlRender({ui}) +} + const project = (events: V2Event[]): SessionMessageInfo[] => events.reduce(applySessionEvent, emptySessionTranscript).messages diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index b9e683f40..7a37cf396 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -479,6 +479,7 @@ const MessageRow = memo(function MessageRow({ @@ -516,6 +517,7 @@ const MessageRow = memo(function MessageRow({ diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index fc5fadc97..8ce719f51 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import type { PermissionRequest, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { ToolCallPart } from './ToolCallPart' import { useUserBash } from '@/stores/userBashStore' +import { useSessionStatus } from '@/stores/sessionStatusStore' const mocks = vi.hoisted(() => ({ useSettings: vi.fn(), @@ -19,8 +20,13 @@ vi.mock('@/contexts/EventContext', () => ({ useToolCallPermission: mocks.useToolCallPermission, })) -const renderWithProviders = (ui: React.ReactElement) => { - const queryClient = new QueryClient() +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + getSession: () => new Promise(() => {}), + listShells: () => Promise.resolve([]), +})) + +const renderWithProviders = (ui: React.ReactElement, queryClient = new QueryClient()) => { return render( {ui} @@ -112,6 +118,13 @@ describe('ToolCallPart background indicator', () => { }) mocks.useToolCallPermission.mockReturnValue(null) useUserBash.setState({ userBashCommands: new Map() }) + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + revision: 0, + }) }) const completedShell = (metadata: Record): SessionMessageAssistantTool => ({ @@ -127,6 +140,20 @@ describe('ToolCallPart background indicator', () => { }, }) + const completedSubagent = (metadata: Record): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_3', + name: 'subagent', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { description: 'Explore' }, + content: [{ type: 'text', text: 'Task moved to the background (session ID: ses_child).' }], + metadata, + }, + }) + + it('marks a shell call that returned while its command keeps running', () => { renderWithProviders() @@ -147,4 +174,64 @@ describe('ToolCallPart background indicator', () => { expect(screen.getByText('background')).toBeInTheDocument() expect(screen.queryByText('✓')).not.toBeInTheDocument() }) + + it('clears the background badge once the live shell completes', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { + id: 'sh_1', + status: 'exited', + exit: 0, + command: 'npm run dev', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/sh_1.log', + metadata: { sessionID: 'ses_1' }, + time: { started: 1, completed: 2 }, + }, + ]) + + renderWithProviders( + , + queryClient, + ) + + await waitFor(() => expect(screen.queryByText('background')).not.toBeInTheDocument()) + expect(screen.getByText('completed')).toBeInTheDocument() + }) + + it('shows a background shell missing from a loaded list as unavailable', async () => { + renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('unavailable')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + }) + + it('treats an unknown child session as running, not finished', () => { + renderWithProviders( + , + ) + + expect(screen.getByText('background')).toBeInTheDocument() + expect(screen.queryByText('✓')).not.toBeInTheDocument() + }) + + it('completes a backgrounded subagent when the child session is known idle', async () => { + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + + renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('✓')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index cdf6f9657..c20b753ed 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -3,12 +3,24 @@ import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' import { toolContentText, type SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' import { useUserBash } from '@/stores/userBashStore' -import { useSessionStatusForSession } from '@/stores/sessionStatusStore' +import { useChildLifecycleForSession } from '@/stores/sessionStatusStore' import { useToolCallPermission } from '@/contexts/EventContext' +import { useChildSessionReconciliation } from '@/hooks/useOpenCode' +import { useShell } from '@/hooks/useSessionShells' import { detectFileReferences } from '@/lib/fileReferences' -import { ExternalLink, Loader2, Shield } from 'lucide-react' +import { ExternalLink, Loader2, Shield, Square } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { CopyButton } from '@/components/ui/copy-button' +import { + backgroundChildSessionID, + backgroundShellID, + isRunningLifecycle, + lifecycleLabel, + shellToolLifecycle, + subagentLifecycle, + toolMetadata, + type BackgroundTaskLifecycle, +} from '@/lib/backgroundWork' import { getToolSpecificRender } from './FileToolRender' const DISPLAY_LIMIT = 30_000 @@ -41,6 +53,7 @@ function BoundedPre({ content, className }: { content: string; className: string interface ToolCallPartProps { part: SessionMessageAssistantTool messageID?: string + directory?: string onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } @@ -50,11 +63,6 @@ function toolInput(part: SessionMessageAssistantTool): Record | return part.state.input } -function toolMetadata(part: SessionMessageAssistantTool): Record { - if (part.state.status === 'streaming') return {} - return part.state.metadata ?? {} -} - function toolOutputText(part: SessionMessageAssistantTool): string { if (part.state.status === 'streaming') return '' return toolContentText(part.state.status === 'running' ? undefined : part.state.content) @@ -105,12 +113,28 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi return
{parts}
} -export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFileClick, onChildSessionClick }: ToolCallPartProps) { +export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() const isSubagent = part.name === 'subagent' const subagentSessionId = isSubagent ? getSubagentSessionId(part) : undefined - const subagentSessionStatus = useSessionStatusForSession(subagentSessionId) + const backgroundSubagent = isSubagent ? backgroundChildSessionID(part) : undefined + const subagentSessionLifecycle = useChildLifecycleForSession(subagentSessionId) + useChildSessionReconciliation( + subagentSessionId && (backgroundSubagent !== undefined || part.state.status === 'running') + ? subagentSessionId + : undefined, + ) + const subagentStatus = subagentLifecycle( + part.state.status, + backgroundSubagent !== undefined, + subagentSessionLifecycle, + ) + const backgroundShell = part.name === 'shell' ? backgroundShellID(part) : undefined + const { shell, listLoaded: shellListLoaded } = useShell(backgroundShell, directory) + const shellStatus = shellToolLifecycle(backgroundShell, shell, shellListLoaded) + const isBackgroundShell = backgroundShell !== undefined + const isBackgroundShellRunning = isBackgroundShell && isRunningLifecycle(shellStatus) const pendingPermission = useToolCallPermission(part.id, messageID) const isWaitingPermission = part.state.status === 'running' && pendingPermission !== null const outputRef = useRef(null) @@ -138,6 +162,12 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile }, [expanded, part.name]) const getStatusColor = () => { + if (isBackgroundShell) { + if (shellStatus === 'completed') return 'text-success' + if (shellStatus === 'failed') return 'text-destructive' + if (shellStatus === 'killed') return 'text-warning' + return 'text-warning' + } switch (part.state.status) { case 'completed': return 'text-success' @@ -151,7 +181,15 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile } } + const renderShellLifecycleIcon = (status: BackgroundTaskLifecycle) => { + if (status === 'completed') return ✓ + if (status === 'failed') return ✗ + if (status === 'killed') return + return + } + const getStatusIcon = () => { + if (isBackgroundShell) return renderShellLifecycleIcon(shellStatus) switch (part.state.status) { case 'completed': return ✓ @@ -193,7 +231,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile const previewText = getPreviewText() const isFileTool = ['read', 'write', 'edit', 'patch'].includes(part.name) - const isBackgrounded = part.state.status === 'completed' && toolMetadata(part).status === 'running' + const isBackgroundSubagent = backgroundSubagent !== undefined + const isBackgroundSubagentRunning = isBackgroundSubagent && isRunningLifecycle(subagentStatus) + const isBackgrounded = isBackgroundShell ? isBackgroundShellRunning : isBackgroundSubagentRunning const backgroundIndicator = isBackgrounded ? (
- {isBackgrounded ? ( - - ) : ( - ✓ - )} + {isBackgroundShell ? renderShellLifecycleIcon(shellStatus) : ✓} $ {command} {sandboxIndicator} @@ -347,7 +383,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile {previewText} ) : null} - {isWaitingPermission ? 'awaiting permission' : part.state.status} + + {isWaitingPermission ? 'awaiting permission' : isBackgroundShell ? lifecycleLabel(shellStatus) : part.state.status} + {expanded && ( diff --git a/frontend/src/components/session/BackgroundWorkBar.test.tsx b/frontend/src/components/session/BackgroundWorkBar.test.tsx index 7cf33ecaa..88e2b626b 100644 --- a/frontend/src/components/session/BackgroundWorkBar.test.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.test.tsx @@ -1,13 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' import type { SessionMessageAssistant, SessionMessageInfo } from '@opencode-manager/shared/opencode' import type { ShellInfo } from '@/api/opencode' +import { clearShellExitRecord, recordShellExit } from '@/lib/backgroundWork' +import { useSessionStatus } from '@/stores/sessionStatusStore' import { BackgroundWorkBar } from './BackgroundWorkBar' const api = vi.hoisted(() => ({ backgroundSession: vi.fn(), + getSession: vi.fn(), listShells: vi.fn(), readShellOutput: vi.fn(), removeShell: vi.fn(), @@ -50,17 +53,68 @@ const runningShellTool = (): SessionMessageAssistant => ({ time: { created: 1 }, }) -function renderBar(messages: SessionMessageInfo[], isSessionActive: boolean) { +const backgroundShellTool = (shellID: string, command = 'npm run dev'): SessionMessageAssistant => ({ + id: 'msg-shell', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [ + { + type: 'tool', + id: 'tool-shell', + name: 'shell', + state: { + status: 'completed', + input: { command }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', shellID }, + }, + time: { created: 1, completed: 2 }, + }, + ], + time: { created: 1 }, +}) + +const backgroundSubagentTool = (childSessionID: string, description = 'Explore'): SessionMessageAssistant => ({ + id: 'msg-sub', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [ + { + type: 'tool', + id: 'tool-sub', + name: 'subagent', + state: { + status: 'completed', + input: { description }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', sessionID: childSessionID }, + }, + time: { created: 1, completed: 2 }, + }, + ], + time: { created: 1 }, +}) + +function renderBar(messages: SessionMessageInfo[], isSessionActive: boolean, onChildSessionClick = vi.fn()) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) return { ...render( - , + , { wrapper }, ), queryClient, + onChildSessionClick, } } @@ -68,9 +122,18 @@ describe('BackgroundWorkBar', () => { beforeEach(() => { vi.clearAllMocks() api.listShells.mockResolvedValue([]) + api.getSession.mockImplementation(() => new Promise(() => {})) api.backgroundSession.mockResolvedValue(undefined) api.removeShell.mockResolvedValue(undefined) api.readShellOutput.mockResolvedValue({ output: '', cursor: 0, size: 0, truncated: false }) + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), + revision: 0, + }) }) it('renders nothing when there is no running or background work', async () => { @@ -93,20 +156,267 @@ describe('BackgroundWorkBar', () => { expect(screen.queryByRole('button', { name: /Move to background/ })).not.toBeInTheDocument() }) - it('lists only this session\'s running shells and kills one', async () => { + it('keeps this session\'s completed shells visible and marks a killed shell', async () => { api.listShells.mockResolvedValue([shell('dev'), shell('other', 'session-2'), shell('done', 'session-1', 'exited')]) renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /2 background tasks$/ })) expect(screen.getByText('npm run dev')).toBeInTheDocument() + expect(screen.getByText('npm run done')).toBeInTheDocument() expect(screen.queryByText('npm run other')).not.toBeInTheDocument() - expect(screen.queryByText('npm run done')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Kill npm run dev' })) await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) - await waitFor(() => expect(screen.queryByText(/background shell/)).not.toBeInTheDocument()) + expect(await screen.findByText('killed')).toBeInTheDocument() + expect(screen.getByText('npm run dev')).toBeInTheDocument() + }) + + it('updates a shell row from running to completed without removing it', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('running')).toBeInTheDocument() + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev', 'session-1', 'exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + }) + + expect(await screen.findByText('completed')).toBeInTheDocument() + expect(screen.getByText('npm run dev')).toBeInTheDocument() + }) + + it('preserves a shell created while a stale list fetch was in flight', async () => { + let resolveList: ((value: ShellInfo[]) => void) | undefined + api.listShells.mockImplementation(() => new Promise((resolve) => { resolveList = resolve })) + const { queryClient } = renderBar([], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev'), time: { started: Date.now() + 60_000 } }, + ]) + }) + + await act(async () => { + resolveList?.([]) + }) + + expect(await screen.findByRole('button', { name: /1 background task$/ })).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + + it('marks a running shell omitted from a fresh list as unavailable', async () => { + api.listShells.mockResolvedValue([]) + const { queryClient } = renderBar([], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [shell('dev')]) + await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) + }) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(await screen.findByText('unavailable')).toBeInTheDocument() + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('does not resurrect a completed shell from a stale list fetch', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('running')).toBeInTheDocument() + + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev', 'session-1', 'exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) + }) + + await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) + expect(screen.getByText('npm run dev')).toBeInTheDocument() + }) + + it('shows a backgrounded subagent and completes it when the child goes idle', async () => { + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('Explore')).toBeInTheDocument() + expect(screen.getByText('running')).toBeInTheDocument() + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) + }) + expect(screen.getByText('running')).toBeInTheDocument() + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + + expect(await screen.findByText('completed')).toBeInTheDocument() + expect(screen.getByText('Explore')).toBeInTheDocument() + }) + + it('treats an unknown child session as still running', async () => { + renderBar([backgroundSubagentTool('child-unknown')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(screen.getByText('running')).toBeInTheDocument() + expect(screen.queryByText('completed')).not.toBeInTheDocument() + }) + + it('reconciles a child session that finished before it was observed', async () => { + api.getSession.mockResolvedValue({ + id: 'child-1', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + outcome: 'succeeded', + time: { created: 1, updated: 2, idle: 2 }, + location: { directory: '/repo' }, + }) + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('shows a failed child session outcome as failed', async () => { + act(() => { + useSessionStatus.getState().setOutcome('child-1', 'failed') + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('failed')).toBeInTheDocument() + expect(screen.queryByText('completed')).not.toBeInTheDocument() + }) + + it('shows an interrupted child session outcome as interrupted', async () => { + act(() => { + useSessionStatus.getState().setOutcome('child-1', 'interrupted') + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('interrupted')).toBeInTheDocument() + expect(screen.queryByText('completed')).not.toBeInTheDocument() + }) + + it('keeps a deleted shell visible as unavailable', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev'), status: 'unavailable', time: { started: 1, completed: 2 } }, + ]) + }) + + expect(await screen.findByText('unavailable')).toBeInTheDocument() + expect(screen.getByText('npm run dev')).toBeInTheDocument() + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('shows a historical shell absent from the fetched list as unavailable', async () => { + api.listShells.mockResolvedValue([]) + renderBar([backgroundShellTool('sh-history', 'npm run history')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('unavailable')).toBeInTheDocument() + expect(screen.getByText('npm run history')).toBeInTheDocument() + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('keeps a historical shell unknown while its list is still loading', async () => { + api.listShells.mockImplementation(() => new Promise(() => {})) + renderBar([backgroundShellTool('sh-history', 'npm run history')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(screen.getByText('running')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + + it('uses an already cached shell list when the bar remounts', async () => { + const messages = [backgroundShellTool('sh-history', 'npm run history')] + const { queryClient, unmount } = renderBar(messages, false) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(await screen.findByText('unavailable')).toBeInTheDocument() + unmount() + + render( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('unavailable')).toBeInTheDocument() + expect(api.listShells).toHaveBeenCalledTimes(1) + }) + + it('does not overwrite a newer live child status with a stale child query', async () => { + let resolveSession: ((value: unknown) => void) | undefined + api.getSession.mockImplementation(() => new Promise((resolve) => { resolveSession = resolve })) + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + expect(screen.getByText('completed')).toBeInTheDocument() + + await act(async () => { + resolveSession?.({ + id: 'child-1', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }) + }) + + expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('applies a shell exit recorded before the list was seeded', async () => { + recordShellExit('/repo', { id: 'sh-seeded', status: 'exited', exit: 0 }) + api.listShells.mockResolvedValue([shell('sh-seeded')]) + renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('completed')).toBeInTheDocument() + clearShellExitRecord('/repo', 'sh-seeded') + }) + + it('navigates to a backgrounded subagent session', async () => { + const { onChildSessionClick } = renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + fireEvent.click(screen.getByRole('button', { name: 'View Explore' })) + + expect(onChildSessionClick).toHaveBeenCalledWith('child-1') }) it('shows the tail of a background shell output', async () => { @@ -114,7 +424,7 @@ describe('BackgroundWorkBar', () => { api.readShellOutput.mockResolvedValue({ output: 'server listening on 3000', cursor: 24, size: 24, truncated: false }) renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) fireEvent.click(screen.getByRole('button', { name: 'Output' })) expect(await screen.findByText('server listening on 3000')).toBeInTheDocument() @@ -136,7 +446,7 @@ describe('BackgroundWorkBar', () => { }) const { queryClient } = renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) fireEvent.click(screen.getByRole('button', { name: 'Output' })) await waitFor(() => expect(calls).toBe(1)) diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx index 83d665630..0375e5d81 100644 --- a/frontend/src/components/session/BackgroundWorkBar.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -1,15 +1,26 @@ -import { useEffect, useRef, useState } from 'react' -import { ChevronDown, ChevronUp, Loader2, MoveDownRight, Square, Terminal } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Ban, Bot, Check, ChevronDown, ChevronUp, ExternalLink, Loader2, MoveDownRight, Square, Terminal, XCircle } from 'lucide-react' import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' -import { readShellOutput, type ShellInfo } from '@/api/opencode' +import { readShellOutput } from '@/api/opencode' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { useBackgroundSession } from '@/hooks/useOpenCode' +import { useBackgroundSession, useChildSessionReconciliation } from '@/hooks/useOpenCode' import { useKillShell, useSessionShells } from '@/hooks/useSessionShells' +import { useSessionStatus } from '@/stores/sessionStatusStore' +import { + BACKGROUNDABLE_TOOLS, + isRunningLifecycle, + lifecycleLabel, + shellBackgroundTasks, + shellLifecycle, + subagentBackgroundTasks, + type BackgroundTask, + type BackgroundTaskLifecycle, + type ShellRecord, +} from '@/lib/backgroundWork' const SHELL_OUTPUT_LIMIT = 50_000 const SHELL_OUTPUT_POLL_INTERVAL_MS = 1000 -const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) function hasBackgroundableWork(messages: SessionMessageInfo[]): boolean { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -89,7 +100,7 @@ function useShellOutput(shellID: string, directory: string, running: boolean): S return state } -function ShellOutputView({ shell, directory, running }: { shell: ShellInfo; directory: string; running: boolean }) { +function ShellOutputView({ shell, directory, running }: { shell: ShellRecord; directory: string; running: boolean }) { const { output, truncated } = useShellOutput(shell.id, directory, running) const bottomRef = useRef(null) @@ -108,28 +119,121 @@ function ShellOutputView({ shell, directory, running }: { shell: ShellInfo; dire ) } +function BackgroundTaskStatusIcon({ status }: { status: BackgroundTaskLifecycle }) { + if (status === 'completed') return + if (status === 'failed') return + if (status === 'interrupted') return + if (status === 'killed') return + if (status === 'unavailable') return + return +} + +function BackgroundTaskKindIcon({ task }: { task: BackgroundTask }) { + return task.kind === 'shell' + ? + : +} + +function BackgroundTaskReconciler({ sessionID }: { sessionID: string }) { + useChildSessionReconciliation(sessionID) + return null +} + +function BackgroundTaskRow({ + task, + killShell, + onChildSessionClick, + onView, +}: { + task: BackgroundTask + killShell: ReturnType + onChildSessionClick?: (sessionId: string) => void + onView: (shell: ShellRecord) => void +}) { + return ( +
  • + + {task.label} + + + {lifecycleLabel(task.status)} + + {task.kind === 'shell' && task.shell && ( + + )} + {task.kind === 'shell' && task.status === 'running' && task.shell && ( + + )} + {task.kind === 'subagent' && task.childSessionID && onChildSessionClick && ( + + )} +
  • + ) +} + interface BackgroundWorkBarProps { sessionID: string directory: string messages: SessionMessageInfo[] isSessionActive: boolean + onChildSessionClick?: (sessionId: string) => void } -export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive }: BackgroundWorkBarProps) { - const shells = useSessionShells(sessionID, directory) +export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive, onChildSessionClick }: BackgroundWorkBarProps) { + const { shells, listLoaded } = useSessionShells(sessionID, directory) + const statuses = useSessionStatus((state) => state.statuses) + const knownSessions = useSessionStatus((state) => state.knownSessions) + const outcomes = useSessionStatus((state) => state.outcomes) const killShell = useKillShell(directory) const backgroundSession = useBackgroundSession() const [expanded, setExpanded] = useState(false) - const [viewing, setViewing] = useState(null) + const [viewing, setViewing] = useState(null) + + const tasks = useMemo( + () => [ + ...shellBackgroundTasks(shells, messages, listLoaded), + ...subagentBackgroundTasks(messages, statuses, knownSessions, outcomes), + ], + [shells, messages, listLoaded, statuses, knownSessions, outcomes], + ) + const hasRunningTask = tasks.some((task) => isRunningLifecycle(task.status)) const canBackground = isSessionActive && hasBackgroundableWork(messages) - const viewingRunning = viewing !== null && shells.some((shell) => shell.id === viewing.id) + const liveViewingShell = viewing ? shells.find((shell) => shell.id === viewing.id) : undefined + const viewingShell = liveViewingShell ?? viewing + const viewingRunning = liveViewingShell !== undefined && isRunningLifecycle(shellLifecycle(liveViewingShell)) - if (!canBackground && shells.length === 0 && viewing === null) return null + if (!canBackground && tasks.length === 0 && viewing === null) return null return (
    -
    + {tasks.map((task) => + task.kind === 'subagent' && task.childSessionID ? ( + + ) : null, + )} +
    {canBackground && ( )}
    - {expanded && shells.length > 0 && ( + {expanded && tasks.length > 0 && (
      - {shells.map((shell) => ( -
    • - - {shell.command} - - -
    • + {tasks.map((task) => ( + ))}
    )} @@ -185,10 +280,12 @@ export function BackgroundWorkBar({ sessionID, directory, messages, isSessionAct { if (!open) setViewing(null) }}> - {viewing?.command} - {viewingRunning ? 'Running in the background' : 'Finished'} + {viewingShell?.command ?? viewing?.command} + + {viewingRunning ? 'Running in the background' : liveViewingShell === undefined ? 'Status unavailable' : 'Finished'} + - {viewing && } + {viewingShell && }
    diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 4b2a4b1ca..2a00d7055 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -169,7 +169,7 @@ describe('EventProvider permissions and forms', () => { mocks.replyForm.mockResolvedValue(undefined) mocks.cancelForm.mockResolvedValue(undefined) mocks.getHealth.mockReturnValue({ isConnected: false, isHealthy: false, lastEventAt: null, isStalled: false }) - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) mocks.subscribeGlobalMonitor.mockReturnValue({ dispose: vi.fn(), updateDirectories: vi.fn(), @@ -1014,6 +1014,131 @@ describe('EventProvider permissions and forms', () => { expect(useSessionStatus.getState().getStatus('session-9')).toEqual({ type: 'idle' }) }) + it('marks a child session as known from its execution lifecycle events', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) + expect(useSessionStatus.getState().isSessionKnown('child-unknown')).toBe(false) + }) + + it('does not treat a child omitted from a global snapshot as finished', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({}) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + const onResync = lastSubscribeCall[0].onResync as (() => void) | undefined + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'busy' }) + + act(() => { + onResync?.() + }) + + await waitFor(() => { + expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(false) + }) + }) + + it('records a failed child outcome and preserves it when the child goes idle', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + + act(() => { + onEvent({ type: 'session.idle', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + }) + + it('records an interrupted child outcome', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.interrupted', data: { sessionID: 'child-1', reason: 'user' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().getOutcome('child-1')).toBe('interrupted') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + }) + + it('does not turn a failed child into completed across a reconnect snapshot', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({}) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + + act(() => { + handleStatusChange(true) + }) + + await waitFor(() => { + expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) + }) + }) + + it('clears a previous child outcome when a new execution starts', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().getOutcome('child-1')).toBeUndefined() + }) + it('reconciles the global active snapshot on reconnect and clears omitted sessions', async () => { mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) mocks.listActiveSessions.mockResolvedValue({ 'session-other': { type: 'running' } }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 23fa42bf1..c6f8ce6a6 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -18,6 +18,7 @@ import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' import { busyStatusesFromActiveSessions, useSessionStatus } from '@/stores/sessionStatusStore' import { + invalidateChildSessionCaches, invalidateProviderCachesDebounced, invalidateQueryKeysDebounced, invalidateRepoGitCachesDebounced, @@ -469,12 +470,24 @@ export function EventProvider({ children }: { children: React.ReactNode }) { break } case 'session.execution.started': { + useSessionStatus.getState().clearOutcome(event.data.sessionID) useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'busy' }) break } - case 'session.execution.succeeded': - case 'session.execution.failed': + case 'session.execution.succeeded': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'succeeded') + useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } + case 'session.execution.failed': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'failed') + useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } case 'session.execution.interrupted': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'interrupted') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) invalidateSessionListCachesDebounced(queryClient, event.directory) break @@ -529,6 +542,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { initialFetchDoneRef.current = false fetchInitialPendingData() fetchInitialSessionStatuses() + invalidateChildSessionCaches(queryClient) startStatusPoll() } else { statusSyncVersionRef.current += 1 @@ -538,6 +552,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const handleResync = () => { void reconcilePendingActionsForDirectories(collectTrackedDirectories()) + void fetchInitialSessionStatuses() + invalidateChildSessionCaches(queryClient) } const initialDirectories = [...new Set((reposRef.current ?? []).map(r => r.fullPath))] diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index 48205d795..9fb1c95c4 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -3,14 +3,16 @@ import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '@opencode-manager/shared/opencode' -import { useCreateSession, useDeleteSession, useSession, useSessionsAcrossDirectories } from './useOpenCode' +import { useChildSessionReconciliation, useCreateSession, useDeleteSession, useSession, useSessionsAcrossDirectories } from './useOpenCode' import { FetchError } from '../api/fetchWrapper' import { showToast } from '../lib/toast' +import { useSessionStatus } from '../stores/sessionStatusStore' const mocks = vi.hoisted(() => ({ listSessionPage: vi.fn(), deleteSession: vi.fn(), createSession: vi.fn(), + getSession: vi.fn(), })) vi.mock('../lib/toast', () => ({ @@ -22,11 +24,13 @@ vi.mock('../lib/toast', () => ({ vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() + mocks.getSession.mockImplementation(actual.getSession) return { ...actual, listSessionPage: mocks.listSessionPage, deleteSession: mocks.deleteSession, createSession: mocks.createSession, + getSession: mocks.getSession, } }) @@ -218,3 +222,91 @@ describe('useCreateSession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/w/a', agent: undefined }) }) }) + +describe('useChildSessionReconciliation', () => { + beforeEach(() => { + mocks.getSession.mockReset() + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), + revision: 0, + }) + }) + + const idleSession = (): SessionInfo => ({ + ...sessionInfo('child-1', '/repo'), + time: { created: 1000, updated: 2000, idle: 2000 }, + }) + + it('applies one guarded snapshot for multiple observers sharing one fetch', async () => { + let resolveSession: ((value: SessionInfo) => void) | undefined + mocks.getSession.mockImplementation( + () => new Promise((resolve) => { resolveSession = resolve }), + ) + + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + const first = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + const second = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledTimes(1)) + + await act(async () => { + resolveSession?.(idleSession()) + }) + + expect(mocks.getSession).toHaveBeenCalledTimes(1) + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) + + first.unmount() + second.unmount() + }) + + it('releases the snapshot when unmounted while the request is pending', async () => { + let resolveSession: ((value: SessionInfo) => void) | undefined + mocks.getSession.mockImplementation( + () => new Promise((resolve) => { resolveSession = resolve }), + ) + + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + const { unmount } = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledTimes(1)) + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) + }) + expect(useSessionStatus.getState().statusRevisions.size).toBeGreaterThan(0) + + unmount() + + await act(async () => { + resolveSession?.(idleSession()) + }) + + await waitFor(() => expect(useSessionStatus.getState().statusRevisions.size).toBe(0)) + }) + + it('clears a stale child outcome when the fresh snapshot is busy', async () => { + mocks.getSession.mockResolvedValue({ + ...sessionInfo('child-1', '/repo'), + outcome: 'failed', + }) + useSessionStatus.getState().setOutcome('child-1', 'failed') + + const queryClient = createQueryClient() + renderHook(() => useChildSessionReconciliation('child-1'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'busy' }) + }) + expect(useSessionStatus.getState().getOutcome('child-1')).toBeUndefined() + }) +}) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index d53f6d46c..812c84114 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -24,7 +24,7 @@ import { parseNetworkError, isGatewayTimeout } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSendErrorStore } from "../stores/sendErrorStore"; import { useSessionStatus } from "../stores/sessionStatusStore"; -import { invalidateSessionListCaches, sessionTranscriptQueryKey } from "../lib/queryInvalidation"; +import { childSessionReconciliationQueryKey, invalidateSessionListCaches, sessionTranscriptQueryKey } from "../lib/queryInvalidation"; import { buildPinnedSessionKeys, buildSessionKey } from "../lib/sessionKey"; import { toggleSessionPin } from "../api/sessionPins"; import { SESSION_PINS_QUERY_KEY } from "./useSessionPins"; @@ -147,6 +147,27 @@ export const useSession = (sessionID: string | undefined, directory?: string) => return useQuery(sessionQueryOptions(sessionID, directory)); }; +export const useChildSessionReconciliation = (sessionID: string | undefined) => { + useQuery({ + queryKey: childSessionReconciliationQueryKey(sessionID), + queryFn: async () => { + const token = useSessionStatus.getState().beginStatusSnapshot(); + try { + const session = await getSession(sessionID!); + useSessionStatus.getState().applySessionSnapshot(sessionID!, session, token); + return session; + } finally { + useSessionStatus.getState().endStatusSnapshot(token); + } + }, + enabled: !!sessionID, + staleTime: 0, + refetchOnMount: 'always', + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }); +}; + export const useCreateSession = ( directory?: string, onSuccess?: (session: { id: string }) => void, diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx index eda0012af..5d282d7d2 100644 --- a/frontend/src/hooks/useSSE.test.tsx +++ b/frontend/src/hooks/useSSE.test.tsx @@ -53,14 +53,14 @@ describe('useSSE', () => { beforeEach(() => { vi.clearAllMocks() MockEventSource.instances = [] - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) useSendErrorStore.setState({ errors: {} }) globalThis.EventSource = MockEventSource as unknown as typeof EventSource globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true } as Response)) }) afterEach(() => { - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) useSendErrorStore.setState({ errors: {} }) globalThis.EventSource = originalEventSource globalThis.fetch = originalFetch @@ -119,6 +119,8 @@ describe('useSSE', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) }) unmount() @@ -357,11 +359,12 @@ describe('useSSE', () => { queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) unmount() }) - it('tracks background shells from shell lifecycle events', async () => { + it('tracks background shells from shell lifecycle events without dropping completed shells', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -401,18 +404,142 @@ describe('useSSE', () => { }) }) - expect(queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.map((shell) => shell.id)) - .toEqual(['shell-2']) + const shells = queryClient.getQueryData>( + ['opencode', 'shells', '/repo'], + ) + expect(shells?.map((shell) => shell.id)).toEqual(['shell-1', 'shell-2']) + expect(shells?.[0]?.status).toBe('exited') + expect(shells?.[0]?.time.completed).toBeDefined() + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.deleted', + directory: '/repo', + data: { id: 'shell-1' }, + }) + }) + + const afterDelete = queryClient.getQueryData>(['opencode', 'shells', '/repo']) + expect(afterDelete?.map((shell) => shell.id)).toEqual(['shell-1', 'shell-2']) + expect(afterDelete?.[0]?.status).toBe('exited') + + unmount() + }) + + it('keeps a deleted running shell visible as unavailable', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { + id: 'shell-1', + status: 'running', + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }, + ]) + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) act(() => { MockEventSource.instances[0].emit('message', { type: 'shell.deleted', directory: '/repo', - data: { id: 'shell-2' }, + data: { id: 'shell-1' }, + }) + }) + + expect( + queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.[0], + ).toMatchObject({ id: 'shell-1', status: 'unavailable' }) + + unmount() + }) + + it('does not discard a shell exit that arrives before the cache is seeded', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) + invalidateQueries.mockClear() + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.exited', + directory: '/repo', + data: { id: 'shell-1', exit: 0, status: 'exited' }, + }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells', '/repo'] }) + + unmount() + }) + + it('does not resurrect a completed shell from a stale created event', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const shell = (status: string) => ({ + id: 'shell-1', + status, + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.created', + directory: '/repo', + data: { info: shell('running') }, }) }) - expect(queryClient.getQueryData(['opencode', 'shells', '/repo'])).toEqual([]) + expect( + queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.[0]?.status, + ).toBe('exited') unmount() }) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 2b4002fe8..f537155d9 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -1,12 +1,19 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SessionInfo, V2Event } from '@opencode-manager/shared/opencode' -import { invalidateSessionListCaches, shellsQueryKey } from '@/lib/queryInvalidation' +import { invalidateChildSessionCaches, invalidateSessionListCaches, shellsQueryKey } from '@/lib/queryInvalidation' +import { + applyShellExit, + markShellDeleted, + recordShellDeleted, + recordShellExit, + upsertShell, + type ShellRecord, +} from '@/lib/backgroundWork' import { showToast } from '@/lib/toast' import { useSendErrorStore } from '@/stores/sendErrorStore' import { openCodeEventStream } from '@/lib/opencode-event-stream' import type { EventStreamSubscription } from '@/lib/opencode-event-stream' -import type { ShellInfo } from '@/api/opencode' type V2StreamEvent = V2Event & { directory?: string } @@ -109,18 +116,34 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) case 'shell.created': { const info = event.data.info - queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => - current ? [...current.filter((shell) => shell.id !== info.id), info] : current, - ) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current) { + queryClient.invalidateQueries({ queryKey: key }) + break + } + queryClient.setQueryData(key, (shells) => upsertShell(shells ?? [], info, cacheDirectory ?? '')) + break + } + + case 'shell.exited': { + recordShellExit(cacheDirectory ?? '', event.data) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current || !current.some((shell) => shell.id === event.data.id)) { + queryClient.invalidateQueries({ queryKey: key }) + break + } + queryClient.setQueryData(key, (shells) => applyShellExit(shells ?? [], event.data)) break } - case 'shell.exited': case 'shell.deleted': { - const id = event.data.id - queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => - current?.filter((shell) => shell.id !== id), - ) + recordShellDeleted(cacheDirectory ?? '', event.data.id) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current || !current.some((shell) => shell.id === event.data.id)) break + queryClient.setQueryData(key, (shells) => markShellDeleted(shells ?? [], event.data.id)) break } @@ -178,6 +201,8 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) if (connected) { setError(null) syncCurrentSession() + queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) + invalidateChildSessionCaches(queryClient) eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) } else { setError('Connection lost. Reconnecting...') @@ -188,6 +213,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) if (!mountedRef.current) return invalidateSessionListCaches(queryClient) queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) + invalidateChildSessionCaches(queryClient) refreshCurrentSession() } diff --git a/frontend/src/hooks/useSessionShells.ts b/frontend/src/hooks/useSessionShells.ts index 0be57f441..6a33d4e00 100644 --- a/frontend/src/hooks/useSessionShells.ts +++ b/frontend/src/hooks/useSessionShells.ts @@ -1,26 +1,50 @@ import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { listShells, removeShell, type ShellInfo } from '@/api/opencode' +import { listShells, removeShell } from '@/api/opencode' +import { reconcileShellList, type ShellRecord } from '@/lib/backgroundWork' import { shellsQueryKey } from '@/lib/queryInvalidation' import { showToast } from '@/lib/toast' -export function useSessionShells(sessionID: string | undefined, directory: string | undefined) { - const query = useQuery({ +function useShellListQuery(directory: string | undefined, enabled: boolean) { + const queryClient = useQueryClient() + + return useQuery({ queryKey: shellsQueryKey(directory), - queryFn: () => listShells(directory ?? ''), - enabled: Boolean(directory), + queryFn: async () => { + const fetchStartedAt = Date.now() + const fetched = await listShells(directory ?? '') + const existing = queryClient.getQueryData(shellsQueryKey(directory)) ?? [] + return reconcileShellList(existing, fetched, fetchStartedAt, directory ?? '') + }, + enabled, staleTime: Infinity, }) +} + +export function useSessionShells(sessionID: string | undefined, directory: string | undefined) { + const query = useShellListQuery(directory, Boolean(directory)) const shells = useMemo( - () => - (query.data ?? []).filter( - (shell) => shell.status === 'running' && shell.metadata.sessionID === sessionID, - ), + () => (query.data ?? []).filter((shell) => shell.metadata.sessionID === sessionID), [query.data, sessionID], ) - return shells + const listLoaded = query.isSuccess + + return { shells, listLoaded } +} + +export function useShell(shellID: string | undefined, directory: string | undefined) { + const query = useShellListQuery(directory, Boolean(directory && shellID)) + + const shell = useMemo( + () => (query.data ?? []).find((candidate) => candidate.id === shellID), + [query.data, shellID], + ) + + const listLoaded = query.isSuccess + + return { shell, listLoaded } } export function useKillShell(directory: string | undefined) { @@ -29,8 +53,12 @@ export function useKillShell(directory: string | undefined) { return useMutation({ mutationFn: (id: string) => removeShell(id, directory ?? ''), onSuccess: (_result, id) => { - queryClient.setQueryData(shellsQueryKey(directory), (current) => - current?.filter((shell) => shell.id !== id), + queryClient.setQueryData(shellsQueryKey(directory), (current) => + current?.map((shell) => + shell.id === id + ? { ...shell, status: 'killed', time: { ...shell.time, completed: shell.time.completed ?? Date.now() } } + : shell, + ), ) }, onError: (error) => { diff --git a/frontend/src/lib/backgroundWork.test.ts b/frontend/src/lib/backgroundWork.test.ts new file mode 100644 index 000000000..734227e8e --- /dev/null +++ b/frontend/src/lib/backgroundWork.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import { + applyShellExit, + childLifecycle, + clearShellExitRecord, + isRunningLifecycle, + markShellDeleted, + reconcileShellList, + recordShellDeleted, + recordShellExit, + shellLifecycle, + shellToolLifecycle, + subagentLifecycle, + upsertShell, + type ShellRecord, +} from './backgroundWork' + +const shell = (id: string, status: ShellRecord['status'], exit?: number, started = 1): ShellRecord => ({ + id, + status, + command: `npm run ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID: 'session-1' }, + time: { started, ...(exit === undefined ? {} : { completed: started + 1 }) }, + ...(exit === undefined ? {} : { exit }), +}) + +describe('shellLifecycle', () => { + it('maps every shell status to a lifecycle state', () => { + expect(shellLifecycle(shell('a', 'running'))).toBe('running') + expect(shellLifecycle(shell('b', 'exited', 0))).toBe('completed') + expect(shellLifecycle(shell('c', 'exited', 1))).toBe('failed') + expect(shellLifecycle(shell('d', 'timeout'))).toBe('failed') + expect(shellLifecycle(shell('e', 'killed'))).toBe('killed') + expect(shellLifecycle(shell('f', 'unavailable'))).toBe('unavailable') + }) + + it('treats an exited shell without an exit code as completed', () => { + expect(shellLifecycle(shell('a', 'exited'))).toBe('completed') + }) + + it('never treats a missing shell as completed', () => { + expect(shellLifecycle(undefined)).toBe('unknown') + expect(isRunningLifecycle(shellLifecycle(undefined))).toBe(true) + }) +}) + +describe('childLifecycle', () => { + it('never treats an unknown child as finished', () => { + expect(childLifecycle({ type: 'idle' }, false, undefined)).toBe('unknown') + }) + + it('maps a known child status to a lifecycle state', () => { + expect(childLifecycle({ type: 'idle' }, true, undefined)).toBe('completed') + expect(childLifecycle({ type: 'idle' }, true, 'succeeded')).toBe('completed') + expect(childLifecycle({ type: 'idle' }, true, 'failed')).toBe('failed') + expect(childLifecycle({ type: 'idle' }, true, 'interrupted')).toBe('interrupted') + expect(childLifecycle({ type: 'busy' }, true, undefined)).toBe('running') + expect(childLifecycle({ type: 'compact' }, true, undefined)).toBe('running') + expect(childLifecycle({ type: 'retry', attempt: 1, message: 'retry', next: 2 }, true, undefined)).toBe('running') + }) +}) + +describe('subagentLifecycle', () => { + it('fails on a tool error', () => { + expect(subagentLifecycle('error', false, 'completed')).toBe('failed') + }) + + it('keeps an unknown backgrounded child running', () => { + expect(subagentLifecycle('completed', true, 'unknown')).toBe('unknown') + }) + + it('completes a backgrounded child once its status is known idle', () => { + expect(subagentLifecycle('completed', true, 'completed')).toBe('completed') + }) + + it('reports a failed backgrounded child as failed', () => { + expect(subagentLifecycle('completed', true, 'failed')).toBe('failed') + }) + + it('reports an interrupted backgrounded child as interrupted', () => { + expect(subagentLifecycle('completed', true, 'interrupted')).toBe('interrupted') + }) + + it('completes a foreground subagent whose child is known idle', () => { + expect(subagentLifecycle('running', false, 'completed')).toBe('completed') + }) + + it('keeps a foreground subagent running while its child status is unknown', () => { + expect(subagentLifecycle('running', false, 'unknown')).toBe('running') + }) +}) + +describe('shellToolLifecycle', () => { + it('reports a non-background shell call as completed', () => { + expect(shellToolLifecycle(undefined, undefined, true)).toBe('completed') + }) + + it('reports a background shell from its live status', () => { + expect(shellToolLifecycle('sh_1', shell('sh_1', 'killed'), true)).toBe('killed') + }) + + it('keeps a shell unknown until the list loads, then unavailable when absent', () => { + expect(shellToolLifecycle('sh_1', undefined, false)).toBe('unknown') + expect(shellToolLifecycle('sh_1', undefined, true)).toBe('unavailable') + }) +}) + +describe('reconcileShellList', () => { + it('preserves a terminal shell when a stale list reports it running', () => { + const cached = shell('a', 'exited', 0) + const fetched = shell('a', 'running') + + expect(reconcileShellList([cached], [fetched], 100, '/repo')).toEqual([cached]) + }) + + it('accepts a terminal status from the list', () => { + const fetched = shell('a', 'exited', 0) + + expect(reconcileShellList([shell('a', 'running')], [fetched], 100, '/repo')).toEqual([fetched]) + }) + + it('preserves a running shell created after a stale fetch started', () => { + const cached = shell('a', 'running', undefined, 200) + + expect(reconcileShellList([cached], [], 100, '/repo')).toEqual([cached]) + }) + + it('preserves terminal history omitted from a fresh fetch', () => { + const cached = shell('a', 'exited', 0, 50) + + expect(reconcileShellList([cached], [], 100, '/repo')).toEqual([cached]) + }) + + it('marks a running shell omitted from a fresh fetch as unavailable', () => { + const cached = shell('a', 'running', undefined, 50) + + const [result] = reconcileShellList([cached], [], 100, '/repo') + + expect(result?.status).toBe('unavailable') + expect(result?.time.completed).toBeDefined() + }) + + it('applies a shell exit recorded before the list was seeded', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + + clearShellExitRecord('/repo', 'a') + }) + + it('applies a shell deletion recorded before the list was seeded', () => { + recordShellDeleted('/repo', 'a') + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('unavailable') + + clearShellExitRecord('/repo', 'a') + }) + + it('does not apply a shell exit recorded for another directory', () => { + recordShellExit('/other', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('running') + + clearShellExitRecord('/other', 'a') + }) +}) + +describe('upsertShell', () => { + it('does not downgrade a terminal shell to running', () => { + const cached = shell('a', 'exited', 0) + + expect(upsertShell([cached], shell('a', 'running'), '/repo')).toEqual([cached]) + }) + + it('does not downgrade an unavailable shell to running', () => { + const cached = shell('a', 'unavailable') + + expect(upsertShell([cached], shell('a', 'running'), '/repo')).toEqual([cached]) + }) + + it('does not resurrect a shell deleted before the cache was seeded', () => { + recordShellDeleted('/repo', 'a') + + const [result] = upsertShell([], shell('a', 'running'), '/repo') + + expect(result?.status).toBe('unavailable') + + clearShellExitRecord('/repo', 'a') + }) + + it('replaces a running shell with the latest info', () => { + const next = shell('a', 'exited', 0) + + expect(upsertShell([shell('a', 'running')], next, '/repo')).toEqual([next]) + }) + + it('appends a newly created shell', () => { + expect(upsertShell([shell('a', 'running')], shell('b', 'running'), '/repo').map((item) => item.id)).toEqual(['a', 'b']) + }) +}) + +describe('applyShellExit', () => { + it('marks a matching shell terminal and records its completion', () => { + const [result] = applyShellExit([shell('a', 'running')], { id: 'a', status: 'exited', exit: 0 }) + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + expect(result?.time.completed).toBeDefined() + }) + + it('does not turn an unavailable shell back into running', () => { + const [result] = applyShellExit([shell('a', 'unavailable')], { id: 'a', status: 'running' }) + + expect(result?.status).toBe('unavailable') + }) + + it('leaves unrelated shells untouched', () => { + const other = shell('b', 'running') + const [result] = applyShellExit([other], { id: 'a', status: 'killed' }) + + expect(result).toBe(other) + }) +}) + +describe('markShellDeleted', () => { + it('keeps the shell visible as unavailable instead of erasing it', () => { + const [result] = markShellDeleted([shell('a', 'running')], 'a') + + expect(result?.id).toBe('a') + expect(result?.status).toBe('unavailable') + }) + + it('does not reset a terminal shell to running', () => { + const [result] = markShellDeleted([shell('a', 'exited', 0)], 'a') + + expect(result?.status).toBe('exited') + }) +}) diff --git a/frontend/src/lib/backgroundWork.ts b/frontend/src/lib/backgroundWork.ts new file mode 100644 index 000000000..b93436c8a --- /dev/null +++ b/frontend/src/lib/backgroundWork.ts @@ -0,0 +1,308 @@ +import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' +import type { SessionMessageAssistantTool, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { ShellInfo } from '@/api/opencode' +import type { SessionStatusType } from '@/stores/sessionStatusStore' + +export const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) + +export type ChildOutcome = 'succeeded' | 'failed' | 'interrupted' +export type ShellLifecycleStatus = ShellInfo['status'] | 'unavailable' +export type ShellRecord = Omit & { status: ShellLifecycleStatus } +export type BackgroundTaskKind = 'shell' | 'subagent' +export type BackgroundTaskStatus = + | 'running' + | 'completed' + | 'failed' + | 'killed' + | 'interrupted' + | 'unavailable' +export type BackgroundTaskLifecycle = BackgroundTaskStatus | 'unknown' + +export interface BackgroundTask { + id: string + kind: BackgroundTaskKind + label: string + status: BackgroundTaskLifecycle + shell?: ShellRecord + childSessionID?: string +} + +interface ShellExitRecord { + status: ShellLifecycleStatus + exit?: number + completedAt: number +} + +const shellExitRecords = new Map() + +const shellExitKey = (directory: string, id: string): string => `${directory}\u0000${id}` + +export function recordShellExit( + directory: string, + exit: { id: string; status: ShellInfo['status']; exit?: number }, +): void { + if (exit.status === 'running') return + shellExitRecords.set(shellExitKey(directory, exit.id), { + status: exit.status, + exit: exit.exit, + completedAt: Date.now(), + }) +} + +export function recordShellDeleted(directory: string, id: string): void { + shellExitRecords.set(shellExitKey(directory, id), { + status: 'unavailable', + completedAt: Date.now(), + }) +} + +export function clearShellExitRecord(directory: string, id: string): void { + shellExitRecords.delete(shellExitKey(directory, id)) +} + +function shellExitRecord(directory: string, id: string): ShellExitRecord | undefined { + return shellExitRecords.get(shellExitKey(directory, id)) +} + +export function toolMetadata(part: SessionMessageAssistantTool): Record { + if (part.state.status === 'streaming') return {} + return part.state.metadata ?? {} +} + +function metadataString(part: SessionMessageAssistantTool, key: string): string | undefined { + const value = toolMetadata(part)[key] + return typeof value === 'string' ? value : undefined +} + +function isBackgroundPart(part: SessionMessageAssistantTool): boolean { + if (part.state.status !== 'completed') return false + const metadata = toolMetadata(part) + return metadata.background === true || metadata.status === 'running' +} + +export function backgroundShellID(part: SessionMessageAssistantTool): string | undefined { + if (part.name !== 'shell' || !isBackgroundPart(part)) return undefined + return metadataString(part, 'shellID') +} + +export function backgroundChildSessionID(part: SessionMessageAssistantTool): string | undefined { + if (part.name !== 'subagent' || !isBackgroundPart(part)) return undefined + return metadataString(part, 'sessionID') +} + +export function isTerminalShell(shell: { status: ShellLifecycleStatus }): boolean { + return shell.status !== 'running' +} + +export function shellLifecycle(shell: ShellRecord | undefined): BackgroundTaskLifecycle { + if (!shell) return 'unknown' + if (shell.status === 'running') return 'running' + if (shell.status === 'killed') return 'killed' + if (shell.status === 'timeout') return 'failed' + if (shell.status === 'unavailable') return 'unavailable' + return shell.exit !== undefined && shell.exit !== 0 ? 'failed' : 'completed' +} + +export function childLifecycle( + status: SessionStatusType, + known: boolean, + outcome: ChildOutcome | undefined, +): BackgroundTaskLifecycle { + if (!known) return 'unknown' + if (status.type !== 'idle') return 'running' + if (outcome === 'failed') return 'failed' + if (outcome === 'interrupted') return 'interrupted' + return 'completed' +} + +export function subagentLifecycle( + toolStatus: SessionMessageAssistantTool['state']['status'], + background: boolean, + childLifecycle: BackgroundTaskLifecycle, +): BackgroundTaskLifecycle { + if (toolStatus === 'error') return 'failed' + if (background) return childLifecycle + if (toolStatus === 'completed') return 'completed' + if (childLifecycle !== 'unknown' && childLifecycle !== 'running') return childLifecycle + return 'running' +} + +export function shellToolLifecycle( + shellID: string | undefined, + shell: ShellRecord | undefined, + listLoaded: boolean, +): BackgroundTaskLifecycle { + if (!shellID) return 'completed' + if (shell) return shellLifecycle(shell) + return listLoaded ? 'unavailable' : 'unknown' +} + +export function isRunningLifecycle(status: BackgroundTaskLifecycle): boolean { + return status === 'running' || status === 'unknown' +} + +export function lifecycleLabel(status: BackgroundTaskLifecycle): string { + return status === 'unknown' ? 'running' : status +} + +function applyShellExitRecord(shell: ShellRecord, record: ShellExitRecord): ShellRecord { + return { + ...shell, + status: record.status, + exit: record.exit ?? shell.exit, + time: { ...shell.time, completed: shell.time.completed ?? record.completedAt }, + } +} + +function markShellUnavailable(shell: ShellRecord): ShellRecord { + return { + ...shell, + status: 'unavailable', + time: { ...shell.time, completed: shell.time.completed ?? Date.now() }, + } +} + +export function reconcileShellList( + existing: ShellRecord[], + fetched: ShellInfo[], + fetchStartedAt: number, + directory: string, +): ShellRecord[] { + const existingByID = new Map(existing.map((shell) => [shell.id, shell])) + const fetchedByID = new Set(fetched.map((shell) => shell.id)) + const merged: ShellRecord[] = fetched.map((shell) => { + const cached = existingByID.get(shell.id) + if (cached && isTerminalShell(cached) && !isTerminalShell(shell)) return cached + const record = shellExitRecord(directory, shell.id) + return record ? applyShellExitRecord(shell, record) : shell + }) + + for (const shell of existing) { + if (fetchedByID.has(shell.id)) continue + if (shell.time.started > fetchStartedAt) { + merged.push(shell) + continue + } + if (isTerminalShell(shell)) { + merged.push(shell) + continue + } + const record = shellExitRecord(directory, shell.id) + merged.push(record ? applyShellExitRecord(shell, record) : markShellUnavailable(shell)) + } + + return merged.sort((left, right) => left.time.started - right.time.started) +} + +export function upsertShell(current: ShellRecord[], info: ShellInfo, directory: string): ShellRecord[] { + const cached = current.find((shell) => shell.id === info.id) + if (cached && isTerminalShell(cached) && !isTerminalShell(info)) return current + const record = shellExitRecord(directory, info.id) + const next = record && !isTerminalShell(info) ? applyShellExitRecord(info, record) : info + return [...current.filter((shell) => shell.id !== info.id), next] +} + +export function applyShellExit( + current: ShellRecord[], + exit: { id: string; status: ShellInfo['status']; exit?: number }, +): ShellRecord[] { + return current.map((shell) => { + if (shell.id !== exit.id || shell.status === 'unavailable') return shell + const terminal = exit.status !== 'running' + return { + ...shell, + status: exit.status, + exit: exit.exit ?? shell.exit, + time: terminal ? { ...shell.time, completed: shell.time.completed ?? Date.now() } : shell.time, + } + }) +} + +export function markShellDeleted(current: ShellRecord[], id: string): ShellRecord[] { + return current.map((shell) => { + if (shell.id !== id || isTerminalShell(shell)) return shell + return markShellUnavailable(shell) + }) +} + +function shellLabel(part: SessionMessageAssistantTool): string { + if (part.state.status === 'streaming') return 'Shell command' + const command = part.state.input.command + if (typeof command !== 'string' || !command) return 'Shell command' + return unwrapSandboxExecCommand(command) +} + +export function shellBackgroundTasks( + shells: ShellRecord[], + messages: SessionMessageInfo[], + listLoaded: boolean, +): BackgroundTask[] { + const tasks = new Map() + for (const shell of shells) { + tasks.set(shell.id, { + id: shell.id, + kind: 'shell', + label: shell.command, + status: shellLifecycle(shell), + shell, + }) + } + + for (const message of messages) { + if (message.type !== 'assistant') continue + for (const part of message.content) { + if (part.type !== 'tool' || part.name !== 'shell') continue + const shellID = backgroundShellID(part) + if (!shellID || tasks.has(shellID)) continue + tasks.set(shellID, { + id: shellID, + kind: 'shell', + label: shellLabel(part), + status: listLoaded ? 'unavailable' : 'unknown', + }) + } + } + + return [...tasks.values()] +} + +function subagentLabel(part: SessionMessageAssistantTool): string { + if (part.state.status !== 'streaming') { + const description = part.state.input.description + if (typeof description === 'string' && description) return description + } + return 'Sub-agent task' +} + +export function subagentBackgroundTasks( + messages: SessionMessageInfo[], + statuses: Map, + knownSessions: Set, + outcomes: Map, +): BackgroundTask[] { + const tasks = new Map() + for (const message of messages) { + if (message.type !== 'assistant') continue + for (const part of message.content) { + if (part.type !== 'tool' || part.name !== 'subagent') continue + const childSessionID = backgroundChildSessionID(part) + if (!childSessionID) continue + tasks.set(childSessionID, { + id: childSessionID, + kind: 'subagent', + label: subagentLabel(part), + status: subagentLifecycle( + part.state.status, + true, + childLifecycle( + statuses.get(childSessionID) ?? { type: 'idle' }, + knownSessions.has(childSessionID), + outcomes.get(childSessionID), + ), + ), + childSessionID, + }) + } + } + return [...tasks.values()] +} diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index 179cdf4b4..4360ea06e 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -9,6 +9,14 @@ export function shellsQueryKey(directory: string | null | undefined) { return ['opencode', 'shells', directory] } +export function childSessionReconciliationQueryKey(sessionID: string | null | undefined) { + return ['opencode', 'session-reconcile', sessionID] +} + +export function invalidateChildSessionCaches(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ['opencode', 'session-reconcile'] }) +} + export function invalidateProviderCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) queryClient.invalidateQueries({ queryKey: ['provider-auth-methods'] }) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 0acad7d6a..3b533bd39 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -638,6 +638,7 @@ export function SessionDetail() { directory={sessionDirectory} messages={messages} isSessionActive={isSessionActive} + onChildSessionClick={handleChildSessionClick} /> { statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), revision: 0, }) } @@ -60,12 +62,172 @@ describe('sessionStatusStore', () => { }) }) - it('leaves the store state untouched for an idle event on an untracked session with no snapshot in flight', () => { + it('records knowledge for an idle event on an untracked session without adding a status', () => { const before = useSessionStatus.getState() before.setStatus('untracked', { type: 'idle' }) - expect(useSessionStatus.getState()).toBe(before) + const after = useSessionStatus.getState() + expect(after.statuses).toBe(before.statuses) + expect(after.getStatus('untracked')).toEqual({ type: 'idle' }) + expect(after.isSessionKnown('untracked')).toBe(true) + }) + + it('keeps knowledge of a session after it goes idle', () => { + const store = useSessionStatus.getState() + store.setStatus('session-a', { type: 'busy' }) + expect(store.isSessionKnown('session-a')).toBe(true) + + store.setStatus('session-a', { type: 'idle' }) + + expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) + }) + + it('marks sessions from a global snapshot as known', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + useSessionStatus.getState().replaceStatuses({ 'session-a': { type: 'busy' } }, token) + + expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(true) + expect(useSessionStatus.getState().isSessionKnown('session-unknown')).toBe(false) + }) + + it('does not treat a session omitted from a later snapshot as finished', () => { + const store = useSessionStatus.getState() + store.setStatus('session-a', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(false) + expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) + }) + + it('records a child outcome and preserves it when the status goes idle', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + store.setOutcome('child-a', 'failed') + + store.setStatus('child-a', { type: 'idle' }) + + expect(useSessionStatus.getState().getOutcome('child-a')).toBe('failed') + expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + }) + + it('clears a child outcome when a new execution starts', () => { + const store = useSessionStatus.getState() + store.setOutcome('child-a', 'interrupted') + + store.clearOutcome('child-a') + + expect(useSessionStatus.getState().getOutcome('child-a')).toBeUndefined() + }) + + it('applies an authoritative idle session snapshot from the child session query', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + outcome: 'failed', + time: { created: 1, updated: 2, idle: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().getOutcome('child-a')).toBe('failed') + }) + + it('applies a running child session snapshot as busy', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) + }) + + it('does not overwrite a newer live terminal event with a stale session snapshot', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + store.setStatus('child-a', { type: 'idle' }) + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'idle' }) + }) + + it('clears a stale child outcome when a fresh authoritative busy snapshot arrives', () => { + const store = useSessionStatus.getState() + store.setOutcome('child-a', 'failed') + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().getOutcome('child-a')).toBeUndefined() + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) + }) + + it('preserves confirmed idle knowledge across a global snapshot', () => { + const store = useSessionStatus.getState() + store.setStatus('child-idle', { type: 'idle' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().isSessionKnown('child-idle')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-idle')).toEqual({ type: 'idle' }) + }) + + it('turns a formerly active session omitted from a global snapshot unknown', () => { + const store = useSessionStatus.getState() + store.setStatus('child-active', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().isSessionKnown('child-active')).toBe(false) + }) + + it('does not let a snapshot erase an idle event received in flight', () => { + const store = useSessionStatus.getState() + store.setStatus('child-active', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + store.setStatus('child-active', { type: 'idle' }) + + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().isSessionKnown('child-active')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-active')).toEqual({ type: 'idle' }) }) it('leaves the statuses map identity unchanged for a repeated identical busy status outside a snapshot', () => { diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts index 7996b0ce2..32a5ea042 100644 --- a/frontend/src/stores/sessionStatusStore.ts +++ b/frontend/src/stores/sessionStatusStore.ts @@ -1,4 +1,6 @@ import { create } from 'zustand' +import type { SessionInfo } from '@opencode-manager/shared/opencode' +import { childLifecycle, type BackgroundTaskLifecycle, type ChildOutcome } from '@/lib/backgroundWork' export type SessionStatusType = | { type: 'idle' } @@ -6,7 +8,7 @@ export type SessionStatusType = | { type: 'compact' } | { type: 'retry'; attempt: number; message: string; next: number } -interface StatusSnapshotToken { +export interface StatusSnapshotToken { revision: number order: number } @@ -15,13 +17,20 @@ interface SessionStatusStore { statuses: Map statusCache: Map statusRevisions: Map + knownSessions: Set + outcomes: Map revision: number setStatus: (sessionID: string, status: SessionStatusType) => void + setOutcome: (sessionID: string, outcome: ChildOutcome) => void + clearOutcome: (sessionID: string) => void setOptimisticActive: (sessionID: string, timeoutMs?: number) => void replaceStatuses: (statuses: Record, token: StatusSnapshotToken) => void + applySessionSnapshot: (sessionID: string, session: SessionInfo, token: StatusSnapshotToken) => void beginStatusSnapshot: () => StatusSnapshotToken endStatusSnapshot: (token: StatusSnapshotToken) => void getStatus: (sessionID: string) => SessionStatusType + getOutcome: (sessionID: string) => ChildOutcome | undefined + isSessionKnown: (sessionID: string) => boolean clearStatus: (sessionID: string) => void } @@ -54,6 +63,21 @@ const sameEntries = (left: Map, right: Map): boolean => return true } +const sameSets = (left: Set, right: Set): boolean => { + if (left.size !== right.size) return false + for (const value of right) { + if (!left.has(value)) return false + } + return true +} + +const markSessionKnown = (knownSessions: Set, sessionID: string): Set | null => { + if (knownSessions.has(sessionID)) return null + const next = new Set(knownSessions) + next.add(sessionID) + return next +} + const registerSnapshot = (token: StatusSnapshotToken): void => { inFlightSnapshots.set(token.order, token.revision) } @@ -107,13 +131,27 @@ export const useSessionStatus = create((set, get) => { const statusCache = new Map(state.statusCache) statuses.set(sessionID, status) statusCache.set(sessionID, getStatusHash(status)) - return { statuses, statusCache, ...(recordRevisionPatch(state, sessionID) ?? {}) } + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + return { + statuses, + statusCache, + ...(knownSessions ? { knownSessions } : {}), + ...(recordRevisionPatch(state, sessionID) ?? {}), + } + } + + const isLiveAfterCapture = (sessionID: string, captureRevision: number): boolean => { + if (optimisticActiveTimers.has(sessionID)) return true + const touched = get().statusRevisions.get(sessionID) + return touched !== undefined && touched > captureRevision } return { statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), revision: 0, setStatus: (sessionID: string, status: SessionStatusType) => { @@ -124,14 +162,55 @@ export const useSessionStatus = create((set, get) => { clearOptimisticActiveTimer(sessionID) if (get().statusCache.get(sessionID) === getStatusHash(status)) { - const patch = recordRevisionPatch(get(), sessionID) - if (patch) set(patch) + const revisionPatch = recordRevisionPatch(get(), sessionID) + const knownSessions = markSessionKnown(get().knownSessions, sessionID) + if (revisionPatch || knownSessions) { + set({ + ...(revisionPatch ?? {}), + ...(knownSessions ? { knownSessions } : {}), + }) + } return } set((state) => applyStatusPatch(state, sessionID, status)) }, + setOutcome: (sessionID: string, outcome: ChildOutcome) => { + if (get().outcomes.get(sessionID) === outcome) { + const knownSessions = markSessionKnown(get().knownSessions, sessionID) + if (knownSessions) set({ knownSessions }) + return + } + set((state) => { + const outcomes = new Map(state.outcomes) + outcomes.set(sessionID, outcome) + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + return { outcomes, ...(knownSessions ? { knownSessions } : {}) } + }) + }, + + clearOutcome: (sessionID: string) => { + if (!get().outcomes.has(sessionID)) return + set((state) => { + const outcomes = new Map(state.outcomes) + outcomes.delete(sessionID) + return { outcomes } + }) + }, + + applySessionSnapshot: (sessionID: string, session: SessionInfo, token: StatusSnapshotToken) => { + releaseSnapshot(token) + if (isLiveAfterCapture(sessionID, token.revision)) return + if (session.time.idle !== undefined) { + get().setStatus(sessionID, { type: 'idle' }) + if (session.outcome) get().setOutcome(sessionID, session.outcome) + return + } + get().clearOutcome(sessionID) + get().setStatus(sessionID, { type: 'busy' }) + }, + setOptimisticActive: (sessionID: string, timeoutMs = OPTIMISTIC_ACTIVE_TIMEOUT_MS) => { clearOptimisticActiveTimer(sessionID) @@ -173,25 +252,20 @@ export const useSessionStatus = create((set, get) => { const currentStatuses = get().statuses const currentRevisions = get().statusRevisions + const currentKnownSessions = get().knownSessions const captureRevision = token.revision - const isLiveAfterCapture = (sessionID: string): boolean => { - if (optimisticActiveTimers.has(sessionID)) return true - const touched = currentRevisions.get(sessionID) - return touched !== undefined && touched > captureRevision - } - const newMap = new Map() const newCache = new Map() for (const [sessionID, status] of currentStatuses.entries()) { - if (!isLiveAfterCapture(sessionID)) continue + if (!isLiveAfterCapture(sessionID, captureRevision)) continue newMap.set(sessionID, status) newCache.set(sessionID, getStatusHash(status)) } for (const [sessionID, status] of Object.entries(statuses)) { - if (isLiveAfterCapture(sessionID)) continue + if (isLiveAfterCapture(sessionID, captureRevision)) continue if (status.type === 'idle') continue const current = currentStatuses.get(sessionID) const effective = status.type === 'busy' && current !== undefined && current.type !== 'idle' ? current : status @@ -200,13 +274,28 @@ export const useSessionStatus = create((set, get) => { } const nextRevisions = prunedRevisions(currentRevisions) + const nextKnownSessions = new Set(Object.keys(statuses)) + for (const [sessionID, revision] of currentRevisions.entries()) { + if (revision > captureRevision) nextKnownSessions.add(sessionID) + } + for (const sessionID of optimisticActiveTimers.keys()) { + nextKnownSessions.add(sessionID) + } + for (const sessionID of currentKnownSessions) { + if (!currentStatuses.has(sessionID)) nextKnownSessions.add(sessionID) + } - if (sameEntries(get().statusCache, newCache) && sameEntries(currentRevisions, nextRevisions)) return + if ( + sameEntries(get().statusCache, newCache) && + sameEntries(currentRevisions, nextRevisions) && + sameSets(currentKnownSessions, nextKnownSessions) + ) return set({ statuses: newMap, statusCache: newCache, statusRevisions: nextRevisions, + knownSessions: nextKnownSessions, }) }, @@ -230,18 +319,34 @@ export const useSessionStatus = create((set, get) => { return get().statuses.get(sessionID) || DEFAULT_STATUS }, + getOutcome: (sessionID: string) => { + return get().outcomes.get(sessionID) + }, + + isSessionKnown: (sessionID: string) => { + return get().knownSessions.has(sessionID) + }, + clearStatus: (sessionID: string) => { clearOptimisticActiveTimer(sessionID) - if (!get().statuses.has(sessionID) && inFlightSnapshots.size === 0) return + const knownPatch = markSessionKnown(get().knownSessions, sessionID) + if (!get().statuses.has(sessionID) && inFlightSnapshots.size === 0) { + if (knownPatch) set({ knownSessions: knownPatch }) + return + } set((state) => { const revisionPatch = recordRevisionPatch(state, sessionID) - if (!state.statuses.has(sessionID)) return revisionPatch ?? state + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + const knownFields = knownSessions ? { knownSessions } : {} + if (!state.statuses.has(sessionID)) { + return { ...(revisionPatch ?? {}), ...knownFields } + } const statuses = new Map(state.statuses) const statusCache = new Map(state.statusCache) statuses.delete(sessionID) statusCache.delete(sessionID) - return { statuses, statusCache, ...(revisionPatch ?? {}) } + return { statuses, statusCache, ...(revisionPatch ?? {}), ...knownFields } }) }, } @@ -252,3 +357,15 @@ export const useSessionStatusForSession = (sessionID: string | undefined): Sessi sessionID ? (state.statuses.get(sessionID) ?? DEFAULT_STATUS) : DEFAULT_STATUS ) } + +export const useIsSessionKnown = (sessionID: string | undefined): boolean => { + return useSessionStatus((state) => (sessionID ? state.knownSessions.has(sessionID) : false)) +} + +export const useChildLifecycleForSession = (sessionID: string | undefined): BackgroundTaskLifecycle => { + return useSessionStatus((state) => { + if (!sessionID) return 'unknown' + const status = state.statuses.get(sessionID) ?? DEFAULT_STATUS + return childLifecycle(status, state.knownSessions.has(sessionID), state.outcomes.get(sessionID)) + }) +} From 409f2c49999611fc323c72d98608196413d4d8cc Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:47:13 +0000 Subject: [PATCH 2/3] fix: complete scheduled runs and harden background task status - Route directory-less OpenCode session events (session.execution.*, session.usage.updated) to backend listeners by remembering each session's directory in the SSE aggregator, so scheduled runs finish and failure push notifications fire; browser delivery is unchanged. - Resolve background shell status from the transcript completion notice when OpenCode no longer lists the shell, keep the first terminal status so a kill is not reported as completed, and share one status icon and colour mapping between the transcript and background bar. - Reconcile child sessions only while unknown or running, trigger it from the EventProvider alone, refresh children that drop to unknown after a poll, and forget deleted sessions. - Fetch the model list and default concurrently while waiting for the schedule model, share configured-model selection, and consolidate schedule startup cancellation cleanup. - Reduce background work re-renders, prune shell exit records, remove dead exports, and document background work in the chat guide. --- backend/src/services/opencode-models.ts | 65 ++---- backend/src/services/schedules.ts | 48 ++--- backend/src/services/sse-aggregator.ts | 44 +++- backend/test/services/opencode-models.test.ts | 18 ++ backend/test/services/schedules.test.ts | 46 +++++ backend/test/services/sse-aggregator.test.ts | 89 ++++++++ docs/features/chat.md | 12 ++ frontend/src/api/providers.ts | 15 +- .../src/components/message/MessagePart.tsx | 5 +- .../components/message/MessageThread.test.tsx | 37 ++++ .../src/components/message/MessageThread.tsx | 17 ++ .../components/message/ToolCallPart.test.tsx | 34 ++- .../src/components/message/ToolCallPart.tsx | 34 ++- .../session/BackgroundTaskStatusIcon.tsx | 12 ++ .../session/BackgroundWorkBar.test.tsx | 39 ++++ .../components/session/BackgroundWorkBar.tsx | 40 ++-- frontend/src/contexts/EventContext.test.tsx | 98 ++++++++- frontend/src/contexts/EventContext.tsx | 19 +- frontend/src/hooks/useOpenCode.test.tsx | 28 ++- frontend/src/hooks/useOpenCode.ts | 13 +- frontend/src/hooks/useSSE.test.tsx | 67 +++++- frontend/src/hooks/useSSE.ts | 8 +- frontend/src/hooks/useSessionShells.test.tsx | 161 +++++++++++++++ frontend/src/hooks/useSessionShells.ts | 31 +-- frontend/src/lib/backgroundWork.test.ts | 138 +++++++++++++ frontend/src/lib/backgroundWork.ts | 194 ++++++++++++------ frontend/src/lib/queryInvalidation.ts | 12 +- .../src/stores/sessionStatusStore.test.ts | 68 ++++-- frontend/src/stores/sessionStatusStore.ts | 46 +++-- shared/src/opencode/index.ts | 2 +- shared/src/opencode/modelRef.ts | 20 +- 31 files changed, 1189 insertions(+), 271 deletions(-) create mode 100644 frontend/src/components/session/BackgroundTaskStatusIcon.tsx create mode 100644 frontend/src/hooks/useSessionShells.test.tsx diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index a6fd022d3..e99083541 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -1,5 +1,5 @@ -import type { ConfigEntry, ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' -import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' +import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from './opencode/client' export interface ResolvedOpenCodeModel { @@ -17,8 +17,6 @@ export interface ResolveOpenCodeModelOptions { export const MODEL_LOAD_TIMEOUT_MS = 15_000 export const MODEL_LOAD_POLL_MS = 500 -type ConfigDocumentModel = Extract['info']['model'] - function normalizeModelCandidate(model: string | null | undefined): string | null { if (!model) { return null @@ -70,22 +68,6 @@ function resolveFromLoadedModels( return fallback ? toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) : null } -function toConfiguredRef(model: ConfigDocumentModel): ModelRef | undefined { - if (!model) { - return undefined - } - - if (typeof model === 'string') { - return parseOpenCodeModelRef(model) - } - - return { - providerID: model.providerID, - id: model.model, - ...(model.variant ? { variant: model.variant } : {}), - } -} - function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { if (signal.aborted) { @@ -154,11 +136,7 @@ async function readConfiguredRef( signal: AbortSignal, ): Promise { const entries = await client.api.config.get(location, { signal }) - const model = entries.reduce( - (current, entry) => (entry.type === 'document' && entry.info.model ? entry.info.model : current), - undefined, - ) - return toConfiguredRef(model) + return selectConfiguredModelRef(entries) } export async function resolveOpenCodeModel( @@ -193,31 +171,30 @@ export async function resolveOpenCodeModel( break } - const listResult = await runPollingRequest( - () => client.api.model.list(location, { signal }), - external, - timeoutController.signal, - ) - if (!listResult.ok) { - break + const [listResult, defaultResult] = await Promise.all([ + runPollingRequest( + () => client.api.model.list(location, { signal }), + external, + timeoutController.signal, + ), + runPollingRequest( + () => client.api.model.default(location, { signal }), + external, + timeoutController.signal, + ), + ]) + + if (listResult.ok) { + models = listResult.value.data } - models = listResult.value.data - external?.throwIfAborted() - - if (timeoutController.signal.aborted) { - break + if (defaultResult.ok) { + defaultModel = defaultResult.value.data } - const defaultResult = await runPollingRequest( - () => client.api.model.default(location, { signal }), - external, - timeoutController.signal, - ) - if (!defaultResult.ok) { + if (!listResult.ok || !defaultResult.ok) { break } - defaultModel = defaultResult.value.data external?.throwIfAborted() diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index b0ef82b70..2499fecd2 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -303,13 +303,8 @@ export class ScheduleService { } private static settleCancel(jobId: number, runId: number, status: ScheduleRun['status'] | undefined): void { - const pending = ScheduleService.pendingCancels.get(jobId) - if (pending?.runId !== runId || pending.resolved) { - return - } - - pending.resolved = true - pending.resolve(status === 'cancelled' ? 'cancelled' : status === 'completed' || status === 'failed' ? 'settled' : 'resume') + const decision: CancelDecision = status === 'cancelled' ? 'cancelled' : status === 'completed' || status === 'failed' ? 'settled' : 'resume' + ScheduleService.resolveCancel(jobId, runId, decision) } private static resolveCancel(jobId: number, runId: number, decision: CancelDecision): void { @@ -579,9 +574,7 @@ export class ScheduleService { } if (abort.signal.aborted) { - ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) - return this.loadRun(repoId, jobId, run.id, run) + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) } const runJob = { ...job, agentSlug: await this.resolveRunAgent(runDirectory, job.agentSlug) } @@ -591,9 +584,7 @@ export class ScheduleService { }) if (abort.signal.aborted) { - ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) - return this.loadRun(repoId, jobId, run.id, run) + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) } const sessionTitle = buildSessionTitle(job) @@ -608,18 +599,13 @@ export class ScheduleService { }) } catch (error) { if (abort.signal.aborted) { - ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) - return this.loadRun(repoId, jobId, run.id, run) + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) } throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } if (abort.signal.aborted) { - await this.interruptCancelledSession(session.id) - ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) - return this.loadRun(repoId, jobId, run.id, run) + return this.abandonCancelledStartup(repoId, jobId, run, job, repo, session.id) } const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { @@ -653,10 +639,7 @@ export class ScheduleService { return runWithSession } catch (error) { if (abort.signal.aborted) { - ScheduleService.clearCancel(jobId, run.id) - await this.teardownWorktree(repoId, jobId, run.id, job, repo) - ScheduleService.releaseActiveRun(jobId, run.id) - return this.loadRun(repoId, jobId, run.id, run) + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) } const finishedAt = Date.now() @@ -1124,6 +1107,23 @@ export class ScheduleService { return getScheduleRunById(this.db, repoId, jobId, runId) ?? fallback } + private async abandonCancelledStartup( + repoId: number, + jobId: number, + run: ScheduleRun, + job: ScheduleJob, + repo: Repo, + sessionId?: string, + ): Promise { + if (sessionId) { + await this.interruptCancelledSession(sessionId) + } + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + ScheduleService.releaseActiveRun(jobId, run.id) + return this.loadRun(repoId, jobId, run.id, run) + } + private async teardownWorktree(repoId: number, jobId: number, runId: number, job: ScheduleJob, repo: Repo): Promise { const key = `${repoId}:${jobId}:${runId}` if (ScheduleService.activeTeardowns.has(key)) return diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 7bd8539fb..2636192c2 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -1,7 +1,7 @@ import { EventSource } from 'eventsource' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' -import { openCodeLocation, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' import { getOpenCodeUpstreamBaseUrl } from './opencode/upstream' import { encodeSSEFrame } from '../utils/sse-frame' @@ -44,6 +44,7 @@ class SSEAggregator { private clients: Map = new Map() private directoryClients: Map> = new Map() private activeSessions: Map> = new Map() + private sessionDirectories: Map = new Map() private eventListeners: Set = new Set() private subagentSessions: Map> = new Map() private upstream: EventSource | null = null @@ -284,12 +285,14 @@ class SSEAggregator { for (const ref of this.getScheduledSessions()) { tracked.set(ref.sessionID, ref.directory) } + tracked.forEach((directory, sessionID) => this.sessionDirectories.set(sessionID, directory)) return tracked } private async resolveSessionDirectory(fetcher: PendingActionsFetcher, sessionID: string): Promise { try { const session = await fetcher.api.session.get({ sessionID }) + this.sessionDirectories.set(sessionID, session.location.directory) return session.location.directory } catch (error) { logger.warn(`replay: failed to resolve directory for session ${sessionID}: ${String(error)}`) @@ -403,24 +406,35 @@ class SSEAggregator { const payloadJson = MULTILINE_PATTERN.test(data) ? JSON.stringify(event) : data const directory = event.location?.directory + const sessionID = sessionIDFromEvent(event) try { + const resolvedDirectory = directory ?? (sessionID ? this.sessionDirectories.get(sessionID) : undefined) + + if (sessionID && event.type === 'session.deleted') { + this.sessionDirectories.delete(sessionID) + } else if (directory && sessionID) { + this.sessionDirectories.set(sessionID, directory) + } + if (directory) { this.deliverEvent(directory, event, payloadJson) - } else { - this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) + return } + + if (resolvedDirectory) { + this.deliverResolvedEvent(resolvedDirectory, event, payloadJson) + return + } + + this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) } catch (error) { logger.error(`SSE failed to handle ${event.type} event:`, error) } } private deliverEvent(directory: string, event: SSEEvent, payloadJson?: string): void { - this.handleEvent(directory, event) - - this.eventListeners.forEach(listener => { - try { listener(directory, event) } catch { /* ignore listener errors */ } - }) + this.notifyEvent(directory, event) const subscriberIds = this.directoryClients.get(directory) if (!subscriberIds || subscriberIds.size === 0) return @@ -428,6 +442,19 @@ class SSEAggregator { this.writeEnvelopeToClients(subscriberIds, directory, payloadJson ?? JSON.stringify(event)) } + private deliverResolvedEvent(directory: string, event: SSEEvent, payloadJson: string): void { + this.notifyEvent(directory, event) + this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) + } + + private notifyEvent(directory: string, event: SSEEvent): void { + this.handleEvent(directory, event) + + this.eventListeners.forEach(listener => { + try { listener(directory, event) } catch { /* ignore listener errors */ } + }) + } + private writeEnvelopeToClients(clientIds: Iterable, directory: string | null, payloadJson: string): void { let frame: Uint8Array | null = null for (const clientId of clientIds) { @@ -594,6 +621,7 @@ class SSEAggregator { this.upstreamConnected = false this.activeSessions.clear() + this.sessionDirectories.clear() this.subagentSessions.clear() this.directoryClients.clear() this.clients.clear() diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts index a0a7313df..0d9514ffe 100644 --- a/backend/test/services/opencode-models.test.ts +++ b/backend/test/services/opencode-models.test.ts @@ -225,6 +225,24 @@ describe('resolveOpenCodeModel', () => { expect(listCalls).toBe(3) }) + it('resolves an object configured model and preserves its variant', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + configEntries: [ + { type: 'document', info: { model: { providerID: 'openai', model: 'gpt-5', variant: 'high' } } } as ConfigEntry, + ], + }) + + const result = await resolveOpenCodeModel(client, DIR) + + expect(result).toEqual({ + providerID: 'openai', + id: 'gpt-5', + variant: 'high', + model: 'openai/gpt-5#high', + }) + }) + it('does not resolve a same-provider fallback before the target model loads', async () => { const client = createClientStub({ models: [model('openai', 'gpt-4')], diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 574a4bf4d..a5861f9b0 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -386,6 +386,52 @@ describe('ScheduleService', () => { }) }) + it('completes from a directory-less session.execution.succeeded resolved to the run directory', async () => { + const stub = createStubScheduleApi({ + sessionID: 'ses-run-exec', + active: { 'ses-run-exec': { type: 'running' } }, + }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-exec', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(stub.api.session.active).toHaveBeenCalled() + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + stub.state.active = {} + stub.state.messages = [assistantMessage('Execution summary.', { completed: true })] + captureEventListener()(repo.fullPath, { + id: 'evt_exec_succeeded', + created: Date.now(), + type: 'session.execution.succeeded', + data: { sessionID: 'ses-run-exec' }, + }) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Execution summary.', + }), + ) + }) + }) + it('keeps waiting when an intermediate assistant step completes while the session is still busy', async () => { const stub = createStubScheduleApi({ sessionID: 'ses-multi', diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index edb400d5c..b8c621c90 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -15,6 +15,7 @@ vi.mock('../../src/utils/logger', () => ({ })) import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { sessionIDFromEvent } from '@opencode-manager/shared/opencode' import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' interface CapturedEvent { @@ -647,3 +648,91 @@ describe('SSEAggregator directory-indexed broadcast', () => { expect(clientB.frames).toHaveLength(1) }) }) + +describe('SSEAggregator directory-less session event routing', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('resolves a directory-less terminal event from the remembered directory for listeners, active sessions, and all clients', () => { + const subscribed = createCapturingClient() + const global = createCapturingClient() + sseAggregator.addClient('resolved-a', subscribed.callback, subscribed.writeFrame, ['/r']) + sseAggregator.addClient('resolved-b', global.callback, global.writeFrame, []) + + const seen: Array<{ directory: string; type: string; sessionID: string }> = [] + sseAggregator.onEvent((directory, event) => { + seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) + }) + + emitRawEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_1'] }) + + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + + expect(seen).toEqual([ + { directory: '/r', type: 'session.execution.started', sessionID: 'ses_1' }, + { directory: '/r', type: 'session.execution.succeeded', sessionID: 'ses_1' }, + ]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + + expect(subscribed.frames.map(parseFrame)).toEqual([ + { + directory: '/r', + payload: { id: 'evt_1', created: 1, type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }, + }, + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }, + }, + ]) + expect(global.frames.map(parseFrame)).toEqual([ + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }, + }, + ]) + }) + + it('broadcasts a directory-less event from an unknown session without notifying listeners', () => { + const client = createCapturingClient() + sseAggregator.addClient('unknown-a', client.callback, client.writeFrame, ['/r']) + + const seen: string[] = [] + sseAggregator.onEvent((_directory, event) => { seen.push(event.type) }) + + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }) + + expect(seen).toEqual([]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(client.frames.map(parseFrame)).toEqual([ + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }, + }, + ]) + }) + + it('forgets the remembered directory when session.deleted arrives', () => { + const client = createCapturingClient() + sseAggregator.addClient('deleted-a', client.callback, client.writeFrame, ['/r']) + + const seen: string[] = [] + sseAggregator.onEvent((_directory, event) => { seen.push(event.type) }) + + emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_del' } }) + emitRawEvent({ type: 'session.deleted', data: { sessionID: 'ses_del' } }) + + seen.length = 0 + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }) + + expect(seen).toEqual([]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(client.frames.map(parseFrame).at(-1)).toEqual({ + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }, + }) + }) +}) diff --git a/docs/features/chat.md b/docs/features/chat.md index a044f1a78..120b6ac4f 100644 --- a/docs/features/chat.md +++ b/docs/features/chat.md @@ -12,6 +12,18 @@ Messages stream in real-time using Server-Sent Events (SSE): ![Chat Conversation](../images/chat-conversation.png) +## Background Work + +While a shell command or subagent is running, **Move to background** above the prompt lets it keep running while the session continues. The background tasks bar lists each background shell and subagent with its status: + +- **running** - still working +- **completed** / **failed** - finished, using the shell exit code or the subagent outcome +- **killed** - you stopped the shell from the bar +- **interrupted** - the subagent was interrupted +- **unavailable** - OpenCode no longer knows the shell and the session has no completion notice for it + +Shell rows can show live output and be killed; subagent rows open the child session. Statuses are reconciled when the connection returns, so work that finished while you were away is shown as finished. After a page reload, a shell you killed is shown as **failed**, which matches OpenCode's own notice for it. + ## Model Selection Click the **model name** in the chat prompt area to open the quick model switcher, where you can switch models, mark favorites, and pick variants without leaving the chat. Each agent keeps its own model selection. See [AI Configuration](ai-config.md#model-selection) for the full reference. diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 584e5d733..6c7a87d26 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/config"; import { settingsApi } from "./settings"; import { fetchWrapper } from "./fetchWrapper"; import { callOpenCode } from "./opencodeApi"; -import { openCodeLocation, type ConfigEntry, type FormAnswer, type ModelInfo } from "@opencode-manager/shared/opencode"; +import { openCodeLocation, selectConfiguredModelRef, type FormAnswer, type ModelInfo } from "@opencode-manager/shared/opencode"; import type { CredentialListResponse, CredentialStatusResponse } from "@opencode-manager/shared/schemas"; import type { OpenCodeConfigFile } from "./types/settings"; @@ -128,20 +128,11 @@ export async function getProviders(directory?: string): Promise } } -type ConfigDocumentModel = Extract["info"]["model"]; - -function formatConfigModel(model: NonNullable): string { - return typeof model === "string" ? model : `${model.providerID}/${model.model}`; -} - export async function getOpenCodeConfigModel(directory?: string): Promise { try { const entries = await callOpenCode((api) => api.config.get(openCodeLocation(directory))); - const model = entries.reduce( - (current, entry) => (entry.type === "document" && entry.info.model ? entry.info.model : current), - undefined, - ); - return model ? formatConfigModel(model) : null; + const ref = selectConfiguredModelRef(entries); + return ref ? `${ref.providerID}/${ref.id}` : null; } catch { return null; } diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 9b6975f27..8f8ac4553 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -3,6 +3,7 @@ import type { SessionMessageAssistant } from '@opencode-manager/shared/opencode' import { TextPart } from './TextPart' import { ToolCallPart } from './ToolCallPart' import { useSettings } from '@/hooks/useSettings' +import type { ShellNoticeOutcome } from '@/lib/backgroundWork' type AssistantContentPart = SessionMessageAssistant['content'][number] @@ -10,11 +11,12 @@ interface MessagePartProps { part: AssistantContentPart messageID?: string directory?: string + shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } -export const MessagePart = memo(function MessagePart({ part, messageID, directory, onFileClick, onChildSessionClick }: MessagePartProps) { +export const MessagePart = memo(function MessagePart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: MessagePartProps) { const { preferences } = useSettings() const simpleChatMode = preferences?.simpleChatMode ?? false const showReasoning = preferences?.showReasoning ?? false @@ -42,6 +44,7 @@ export const MessagePart = memo(function MessagePart({ part, messageID, director part={part} messageID={messageID} directory={directory} + shellOutcome={shellOutcome} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} /> diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 10c149e07..d0865b26d 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -204,6 +204,43 @@ describe('MessageThread', () => { expect(onChildSessionClick).toHaveBeenCalledWith('child-1') }) + it('resolves a background shell tool from its completion notice', () => { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + const messages: SessionMessageInfo[] = [ + userMessage('1', 'Hello'), + assistantMessage('2', [ + { + type: 'tool', + id: 'tool_shell', + name: 'shell', + state: { + status: 'completed', + input: { command: 'npm run dev' }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', shellID: 'sh_notice' }, + }, + time: { created: Date.now(), completed: Date.now() + 100 }, + }, + ]), + { + id: 'syn-shell', + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID: 'sh_notice', state: 'completed', exit: 0 }, + time: { created: Date.now() }, + }, + ] + + const { container } = render( + , + ) + + expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + it('renders a synthetic message that only carries text', () => { setupSettings({ simpleChatMode: false, showReasoning: false }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 7a37cf396..f5332115b 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -22,6 +22,7 @@ import { EditableUserMessage, ClickableUserMessage } from './EditableUserMessage import { useSettings } from '@/hooks/useSettings' import { useTTS } from '@/hooks/useTTS' import { CopyButton } from '@/components/ui/copy-button' +import { backgroundShellID, collectBackgroundParts, type ShellNoticeOutcome } from '@/lib/backgroundWork' function getMessageText(message: SessionMessageInfo): string { switch (message.type) { @@ -364,6 +365,7 @@ interface MessageRowProps { model?: string simpleChatMode: boolean showReasoning: boolean + shellOutcomes: ReadonlyMap } const MessageRow = memo(function MessageRow({ @@ -382,8 +384,11 @@ const MessageRow = memo(function MessageRow({ model, simpleChatMode, showReasoning, + shellOutcomes, }: MessageRowProps) { const messageTextContent = getMessageText(message) + const shellOutcomeFor = (part: SessionMessageAssistant['content'][number]) => + part.type === 'tool' ? shellOutcomes.get(backgroundShellID(part) ?? '') : undefined const streaming = message.type === 'assistant' && message.time.completed === undefined const isEditingThisMessage = editingUserMessageId === message.id const canEditUserMessage = isLastUserMessage && !isSessionBusy @@ -480,6 +485,7 @@ const MessageRow = memo(function MessageRow({ part={part} messageID={message.id} directory={directory} + shellOutcome={shellOutcomeFor(part)} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} /> @@ -518,6 +524,7 @@ const MessageRow = memo(function MessageRow({ part={part} messageID={message.id} directory={directory} + shellOutcome={shellOutcomeFor(part)} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} /> @@ -609,6 +616,15 @@ export const MessageThread = memo(function MessageThread({ const lastUserMessageId = useMemo(() => findLastUserMessageId(messages), [messages]) + const shellOutcomesKey = useMemo( + () => [...collectBackgroundParts(messages).shellNotices].map(([id, outcome]) => `${id}=${outcome}`).join(','), + [messages], + ) + const shellOutcomes = useMemo( + () => new Map(shellOutcomesKey ? shellOutcomesKey.split(',').map((entry) => entry.split('=') as [string, ShellNoticeOutcome]) : []), + [shellOutcomesKey], + ) + const nextAssistantIdByMessageId = useMemo(() => { const map = new Map() let nextAssistantId: string | undefined @@ -658,6 +674,7 @@ export const MessageThread = memo(function MessageThread({ model={model} simpleChatMode={simpleChatMode} showReasoning={showReasoning} + shellOutcomes={shellOutcomes} /> ))} {pending.map((item) => ( diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index 8ce719f51..b98a330fb 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -200,8 +200,8 @@ describe('ToolCallPart background indicator', () => { expect(screen.getByText('completed')).toBeInTheDocument() }) - it('shows a background shell missing from a loaded list as unavailable', async () => { - renderWithProviders( + it('shows a background shell missing from a loaded list as a non-spinning unavailable icon', async () => { + const { container } = renderWithProviders( { await waitFor(() => expect(screen.getByText('unavailable')).toBeInTheDocument()) expect(screen.queryByText('background')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('completes a background shell missing from the list from its notice', async () => { + const { container } = renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('fails a background shell missing from the list from its notice', async () => { + renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() }) it('treats an unknown child session as running, not finished', () => { diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index c20b753ed..f4a8cd3ce 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -8,18 +8,22 @@ import { useToolCallPermission } from '@/contexts/EventContext' import { useChildSessionReconciliation } from '@/hooks/useOpenCode' import { useShell } from '@/hooks/useSessionShells' import { detectFileReferences } from '@/lib/fileReferences' -import { ExternalLink, Loader2, Shield, Square } from 'lucide-react' +import { ExternalLink, Loader2, Shield } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { CopyButton } from '@/components/ui/copy-button' +import { BackgroundTaskStatusIcon } from '@/components/session/BackgroundTaskStatusIcon' import { backgroundChildSessionID, backgroundShellID, + backgroundTaskStatusColor, isRunningLifecycle, lifecycleLabel, shellToolLifecycle, subagentLifecycle, + subagentSessionID, toolMetadata, type BackgroundTaskLifecycle, + type ShellNoticeOutcome, } from '@/lib/backgroundWork' import { getToolSpecificRender } from './FileToolRender' @@ -54,6 +58,7 @@ interface ToolCallPartProps { part: SessionMessageAssistantTool messageID?: string directory?: string + shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } @@ -68,11 +73,6 @@ function toolOutputText(part: SessionMessageAssistantTool): string { return toolContentText(part.state.status === 'running' ? undefined : part.state.content) } -function getSubagentSessionId(part: SessionMessageAssistantTool): string | undefined { - const sessionID = toolMetadata(part).sessionID - return typeof sessionID === 'string' ? sessionID : undefined -} - function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { const jsonString = useMemo(() => JSON.stringify(json, null, 2), [json]) const references = useMemo(() => detectFileReferences(jsonString), [jsonString]) @@ -113,11 +113,11 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi return
    {parts}
    } -export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, onFileClick, onChildSessionClick }: ToolCallPartProps) { +export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() const isSubagent = part.name === 'subagent' - const subagentSessionId = isSubagent ? getSubagentSessionId(part) : undefined + const subagentSessionId = isSubagent ? subagentSessionID(part) : undefined const backgroundSubagent = isSubagent ? backgroundChildSessionID(part) : undefined const subagentSessionLifecycle = useChildLifecycleForSession(subagentSessionId) useChildSessionReconciliation( @@ -132,7 +132,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct ) const backgroundShell = part.name === 'shell' ? backgroundShellID(part) : undefined const { shell, listLoaded: shellListLoaded } = useShell(backgroundShell, directory) - const shellStatus = shellToolLifecycle(backgroundShell, shell, shellListLoaded) + const shellStatus = shellToolLifecycle(backgroundShell, shell, shellListLoaded, shellOutcome) const isBackgroundShell = backgroundShell !== undefined const isBackgroundShellRunning = isBackgroundShell && isRunningLifecycle(shellStatus) const pendingPermission = useToolCallPermission(part.id, messageID) @@ -162,12 +162,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct }, [expanded, part.name]) const getStatusColor = () => { - if (isBackgroundShell) { - if (shellStatus === 'completed') return 'text-success' - if (shellStatus === 'failed') return 'text-destructive' - if (shellStatus === 'killed') return 'text-warning' - return 'text-warning' - } + if (isBackgroundShell) return backgroundTaskStatusColor(shellStatus) switch (part.state.status) { case 'completed': return 'text-success' @@ -181,12 +176,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct } } - const renderShellLifecycleIcon = (status: BackgroundTaskLifecycle) => { - if (status === 'completed') return ✓ - if (status === 'failed') return ✗ - if (status === 'killed') return - return - } + const renderShellLifecycleIcon = (status: BackgroundTaskLifecycle) => ( + + ) const getStatusIcon = () => { if (isBackgroundShell) return renderShellLifecycleIcon(shellStatus) diff --git a/frontend/src/components/session/BackgroundTaskStatusIcon.tsx b/frontend/src/components/session/BackgroundTaskStatusIcon.tsx new file mode 100644 index 000000000..31e1ca444 --- /dev/null +++ b/frontend/src/components/session/BackgroundTaskStatusIcon.tsx @@ -0,0 +1,12 @@ +import { Ban, Check, Loader2, Square, XCircle } from 'lucide-react' +import { backgroundTaskStatusColor, type BackgroundTaskLifecycle } from '@/lib/backgroundWork' + +export function BackgroundTaskStatusIcon({ status, className }: { status: BackgroundTaskLifecycle; className: string }) { + const iconClassName = `${className} ${backgroundTaskStatusColor(status)}` + if (status === 'completed') return + if (status === 'failed') return + if (status === 'interrupted') return + if (status === 'killed') return + if (status === 'unavailable') return + return +} diff --git a/frontend/src/components/session/BackgroundWorkBar.test.tsx b/frontend/src/components/session/BackgroundWorkBar.test.tsx index 88e2b626b..2de3e50bb 100644 --- a/frontend/src/components/session/BackgroundWorkBar.test.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.test.tsx @@ -75,6 +75,18 @@ const backgroundShellTool = (shellID: string, command = 'npm run dev'): SessionM time: { created: 1 }, }) +const shellNotice = ( + shellID: string, + state: 'completed' | 'error', + exit?: number, +): SessionMessageInfo => ({ + id: `notice-${shellID}`, + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID, state, ...(exit === undefined ? {} : { exit }) }, + time: { created: 3 }, +}) + const backgroundSubagentTool = (childSessionID: string, description = 'Explore'): SessionMessageAssistant => ({ id: 'msg-sub', type: 'assistant', @@ -171,6 +183,7 @@ describe('BackgroundWorkBar', () => { await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) expect(await screen.findByText('killed')).toBeInTheDocument() expect(screen.getByText('npm run dev')).toBeInTheDocument() + clearShellExitRecord('/repo', 'dev') }) it('updates a shell row from running to completed without removing it', async () => { @@ -345,6 +358,32 @@ describe('BackgroundWorkBar', () => { expect(screen.queryByText('running')).not.toBeInTheDocument() }) + it('shows a historical shell absent from the list as completed from its notice', async () => { + api.listShells.mockResolvedValue([]) + renderBar( + [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'completed', 0)], + false, + ) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('completed')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + + it('shows a historical shell absent from the list as failed from its error notice', async () => { + api.listShells.mockResolvedValue([]) + renderBar( + [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'error')], + false, + ) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(await screen.findByText('failed')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + it('keeps a historical shell unknown while its list is still loading', async () => { api.listShells.mockImplementation(() => new Promise(() => {})) renderBar([backgroundShellTool('sh-history', 'npm run history')], false) diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx index 0375e5d81..38e8af7b2 100644 --- a/frontend/src/components/session/BackgroundWorkBar.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Ban, Bot, Check, ChevronDown, ChevronUp, ExternalLink, Loader2, MoveDownRight, Square, Terminal, XCircle } from 'lucide-react' +import { Bot, Check, ChevronDown, ChevronUp, ExternalLink, Loader2, MoveDownRight, Square, Terminal } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' import { readShellOutput } from '@/api/opencode' import { Button } from '@/components/ui/button' @@ -7,8 +8,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { useBackgroundSession, useChildSessionReconciliation } from '@/hooks/useOpenCode' import { useKillShell, useSessionShells } from '@/hooks/useSessionShells' import { useSessionStatus } from '@/stores/sessionStatusStore' +import { BackgroundTaskStatusIcon } from './BackgroundTaskStatusIcon' import { BACKGROUNDABLE_TOOLS, + childLifecycle, + collectBackgroundParts, isRunningLifecycle, lifecycleLabel, shellBackgroundTasks, @@ -119,15 +123,6 @@ function ShellOutputView({ shell, directory, running }: { shell: ShellRecord; di ) } -function BackgroundTaskStatusIcon({ status }: { status: BackgroundTaskLifecycle }) { - if (status === 'completed') return - if (status === 'failed') return - if (status === 'interrupted') return - if (status === 'killed') return - if (status === 'unavailable') return - return -} - function BackgroundTaskKindIcon({ task }: { task: BackgroundTask }) { return task.kind === 'shell' ? @@ -155,7 +150,7 @@ function BackgroundTaskRow({ {task.label} - + {lifecycleLabel(task.status)} {task.kind === 'shell' && task.shell && ( @@ -202,9 +197,20 @@ interface BackgroundWorkBarProps { export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive, onChildSessionClick }: BackgroundWorkBarProps) { const { shells, listLoaded } = useSessionShells(sessionID, directory) - const statuses = useSessionStatus((state) => state.statuses) - const knownSessions = useSessionStatus((state) => state.knownSessions) - const outcomes = useSessionStatus((state) => state.outcomes) + const collected = useMemo(() => collectBackgroundParts(messages), [messages]) + const childLifecycles = useSessionStatus( + useShallow((state) => { + const lifecycles: Record = {} + for (const part of collected.subagents) { + lifecycles[part.childSessionID] = childLifecycle( + state.statuses.get(part.childSessionID) ?? { type: 'idle' }, + state.knownSessions.has(part.childSessionID), + state.outcomes.get(part.childSessionID), + ) + } + return lifecycles + }), + ) const killShell = useKillShell(directory) const backgroundSession = useBackgroundSession() const [expanded, setExpanded] = useState(false) @@ -212,10 +218,10 @@ export function BackgroundWorkBar({ sessionID, directory, messages, isSessionAct const tasks = useMemo( () => [ - ...shellBackgroundTasks(shells, messages, listLoaded), - ...subagentBackgroundTasks(messages, statuses, knownSessions, outcomes), + ...shellBackgroundTasks(shells, collected.shells, collected.shellNotices, listLoaded), + ...subagentBackgroundTasks(collected.subagents, childLifecycles), ], - [shells, messages, listLoaded, statuses, knownSessions, outcomes], + [shells, collected, listLoaded, childLifecycles], ) const hasRunningTask = tasks.some((task) => isRunningLifecycle(task.status)) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 2a00d7055..f98efa96b 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -1026,8 +1026,8 @@ describe('EventProvider permissions and forms', () => { onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) }) - expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) - expect(useSessionStatus.getState().isSessionKnown('child-unknown')).toBe(false) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-unknown')).toBe(false) }) it('does not treat a child omitted from a global snapshot as finished', async () => { @@ -1052,7 +1052,7 @@ describe('EventProvider permissions and forms', () => { }) await waitFor(() => { - expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(false) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(false) }) }) @@ -1069,14 +1069,14 @@ describe('EventProvider permissions and forms', () => { onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) act(() => { onEvent({ type: 'session.idle', data: { sessionID: 'child-1' }, directory: '/repo' }) }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') }) it('records an interrupted child outcome', async () => { @@ -1091,7 +1091,7 @@ describe('EventProvider permissions and forms', () => { onEvent({ type: 'session.execution.interrupted', data: { sessionID: 'child-1', reason: 'user' }, directory: '/repo' }) }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBe('interrupted') + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('interrupted') expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) }) @@ -1110,16 +1110,16 @@ describe('EventProvider permissions and forms', () => { act(() => { onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') act(() => { handleStatusChange(true) }) await waitFor(() => { - expect(useSessionStatus.getState().getOutcome('child-1')).toBe('failed') + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) - expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) }) }) @@ -1136,7 +1136,85 @@ describe('EventProvider permissions and forms', () => { onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBeUndefined() + expect(useSessionStatus.getState().outcomes.get('child-1')).toBeUndefined() + }) + + it('forgets a deleted session status, knowledge, and outcome while keeping the list invalidation', async () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['opencode', 'sessions', '/repo'], { pages: [], pageParams: [] }) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + + act(() => { + onEvent({ type: 'session.deleted', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-1')).toBe(false) + expect(after.knownSessions.has('child-1')).toBe(false) + expect(after.outcomes.has('child-1')).toBe(false) + + await waitFor(() => { + expect(queryClient.getQueryState(['opencode', 'sessions', '/repo'])?.isInvalidated).toBe(true) + }) + }) + + it('invalidates only the reconciliation of children that moved from busy to unknown during a poll', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions + .mockResolvedValueOnce({ 'child-1': { type: 'running' }, 'child-2': { type: 'running' } }) + .mockResolvedValue({ 'child-2': { type: 'running' } }) + + const queryClient = createTestQueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + queryClient.setQueryData(['opencode', 'session-reconcile', 'child-1'], { id: 'child-1' }) + queryClient.setQueryData(['opencode', 'session-reconcile', 'child-2'], { id: 'child-2' }) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + vi.useFakeTimers() + try { + act(() => { + handleStatusChange(true) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + invalidateQueries.mockClear() + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile', 'child-1'], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile', 'child-2'], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile'], + }) + } finally { + vi.useRealTimers() + } }) it('reconciles the global active snapshot on reconnect and clears omitted sessions', async () => { diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index c6f8ce6a6..4b71656fe 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -393,17 +393,28 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) return } + const busyBeforeSnapshot = new Set() + for (const [sessionID, status] of useSessionStatus.getState().statuses.entries()) { + if (status.type !== 'idle') busyBeforeSnapshot.add(sessionID) + } useSessionStatus.getState().replaceStatuses( busyStatusesFromActiveSessions(active), snapshotToken, ) + const knownAfterSnapshot = useSessionStatus.getState().knownSessions + const becameUnknown = [...busyBeforeSnapshot].filter( + (sessionID) => !knownAfterSnapshot.has(sessionID), + ) + if (becameUnknown.length > 0) { + invalidateChildSessionCaches(queryClient, becameUnknown) + } } catch (error) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) if (import.meta.env.DEV) { console.warn('Failed to fetch active sessions:', error) } } - }, []) + }, [queryClient]) const stopStatusPoll = useCallback(() => { if (statusPollIntervalRef.current === null) return @@ -492,9 +503,13 @@ export function EventProvider({ children }: { children: React.ReactNode }) { invalidateSessionListCachesDebounced(queryClient, event.directory) break } + case 'session.deleted': { + useSessionStatus.getState().forgetSession(event.data.sessionID) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } case 'session.created': case 'session.renamed': - case 'session.deleted': case 'session.moved': case 'session.metadata.updated': invalidateSessionListCachesDebounced(queryClient, event.directory) diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index 9fb1c95c4..e5019ca66 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -260,7 +260,7 @@ describe('useChildSessionReconciliation', () => { expect(mocks.getSession).toHaveBeenCalledTimes(1) expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) - expect(useSessionStatus.getState().isSessionKnown('child-1')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) first.unmount() second.unmount() @@ -293,10 +293,8 @@ describe('useChildSessionReconciliation', () => { }) it('clears a stale child outcome when the fresh snapshot is busy', async () => { - mocks.getSession.mockResolvedValue({ - ...sessionInfo('child-1', '/repo'), - outcome: 'failed', - }) + mocks.getSession.mockResolvedValue(sessionInfo('child-1', '/repo')) + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) useSessionStatus.getState().setOutcome('child-1', 'failed') const queryClient = createQueryClient() @@ -305,8 +303,24 @@ describe('useChildSessionReconciliation', () => { }) await waitFor(() => { - expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'busy' }) + expect(useSessionStatus.getState().statuses.get('child-1')).toEqual({ type: 'busy' }) + }) + expect(useSessionStatus.getState().outcomes.get('child-1')).toBeUndefined() + }) + + it('does not reconcile a child whose lifecycle is already terminal', async () => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + + const queryClient = createQueryClient() + renderHook(() => useChildSessionReconciliation('child-1'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await Promise.resolve() }) - expect(useSessionStatus.getState().getOutcome('child-1')).toBeUndefined() + + expect(mocks.getSession).not.toHaveBeenCalled() + expect(queryClient.getQueryState(['opencode', 'session-reconcile', 'child-1'])?.fetchStatus).not.toBe('fetching') }) }) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 812c84114..a4f189b6a 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -23,7 +23,8 @@ import type { ModelRef, SessionInfo } from "@opencode-manager/shared/opencode"; import { parseNetworkError, isGatewayTimeout } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSendErrorStore } from "../stores/sendErrorStore"; -import { useSessionStatus } from "../stores/sessionStatusStore"; +import { useSessionStatus, useChildLifecycleForSession } from "../stores/sessionStatusStore"; +import { isRunningLifecycle } from "../lib/backgroundWork"; import { childSessionReconciliationQueryKey, invalidateSessionListCaches, sessionTranscriptQueryKey } from "../lib/queryInvalidation"; import { buildPinnedSessionKeys, buildSessionKey } from "../lib/sessionKey"; import { toggleSessionPin } from "../api/sessionPins"; @@ -148,6 +149,7 @@ export const useSession = (sessionID: string | undefined, directory?: string) => }; export const useChildSessionReconciliation = (sessionID: string | undefined) => { + const lifecycle = useChildLifecycleForSession(sessionID) useQuery({ queryKey: childSessionReconciliationQueryKey(sessionID), queryFn: async () => { @@ -160,11 +162,10 @@ export const useChildSessionReconciliation = (sessionID: string | undefined) => useSessionStatus.getState().endStatusSnapshot(token); } }, - enabled: !!sessionID, - staleTime: 0, - refetchOnMount: 'always', - refetchOnReconnect: true, - refetchOnWindowFocus: true, + enabled: !!sessionID && isRunningLifecycle(lifecycle), + staleTime: 5_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }); }; diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx index 5d282d7d2..d014d512a 100644 --- a/frontend/src/hooks/useSSE.test.tsx +++ b/frontend/src/hooks/useSSE.test.tsx @@ -3,6 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useSSE } from './useSSE' +import { useKillShell } from './useSessionShells' +import { clearShellExitRecord } from '@/lib/backgroundWork' import { useSessionStatus } from '../stores/sessionStatusStore' import { useSendErrorStore } from '../stores/sendErrorStore' import { showToast } from '@/lib/toast' @@ -17,6 +19,16 @@ vi.mock('@/lib/toast', () => ({ }, })) +const shellApi = vi.hoisted(() => ({ + listShells: vi.fn(), + removeShell: vi.fn(), +})) + +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + ...shellApi, +})) + class MockEventSource { static instances: MockEventSource[] = [] @@ -53,6 +65,8 @@ describe('useSSE', () => { beforeEach(() => { vi.clearAllMocks() MockEventSource.instances = [] + shellApi.listShells.mockResolvedValue([]) + shellApi.removeShell.mockResolvedValue(undefined) useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) useSendErrorStore.setState({ errors: {} }) globalThis.EventSource = MockEventSource as unknown as typeof EventSource @@ -120,7 +134,7 @@ describe('useSSE', () => { queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) }) unmount() @@ -359,7 +373,7 @@ describe('useSSE', () => { queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) unmount() }) @@ -544,6 +558,55 @@ describe('useSSE', () => { unmount() }) + it('keeps a killed shell killed after a later shell.exited event', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [ + { + id: 'shell-1', + status: 'running', + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }, + ]) + + const { result, unmount } = renderHook( + () => ({ sse: useSSE('/repo', 'session-1'), kill: useKillShell('/repo') }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.sse.isConnected).toBe(true)) + + await act(async () => { + await result.current.kill.mutateAsync('shell-1') + }) + + expect(queryClient.getQueryData>(key)?.[0]?.status).toBe('killed') + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.exited', + directory: '/repo', + data: { id: 'shell-1', status: 'exited' }, + }) + }) + + expect(queryClient.getQueryData>(key)?.[0]?.status).toBe('killed') + + unmount() + clearShellExitRecord('/repo', 'shell-1') + }) + it('does not write session status from stream events', async () => { const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index f537155d9..5d9f18d88 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SessionInfo, V2Event } from '@opencode-manager/shared/opencode' -import { invalidateChildSessionCaches, invalidateSessionListCaches, shellsQueryKey } from '@/lib/queryInvalidation' +import { invalidateSessionListCaches, invalidateShellCaches, shellsQueryKey } from '@/lib/queryInvalidation' import { applyShellExit, markShellDeleted, @@ -201,8 +201,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) if (connected) { setError(null) syncCurrentSession() - queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) - invalidateChildSessionCaches(queryClient) + invalidateShellCaches(queryClient) eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) } else { setError('Connection lost. Reconnecting...') @@ -212,8 +211,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) const handleResync = () => { if (!mountedRef.current) return invalidateSessionListCaches(queryClient) - queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) - invalidateChildSessionCaches(queryClient) + invalidateShellCaches(queryClient) refreshCurrentSession() } diff --git a/frontend/src/hooks/useSessionShells.test.tsx b/frontend/src/hooks/useSessionShells.test.tsx new file mode 100644 index 000000000..ec384b3d6 --- /dev/null +++ b/frontend/src/hooks/useSessionShells.test.tsx @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useKillShell, useShell } from './useSessionShells' +import { applyShellExit, clearShellExitRecord, recordShellExit, type ShellRecord } from '@/lib/backgroundWork' + +const api = vi.hoisted(() => ({ + listShells: vi.fn(), + removeShell: vi.fn(), +})) + +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), loading: vi.fn() }, +})) + +const shell = (id: string, sessionID = 'session-1'): ShellRecord => ({ + id, + status: 'running', + command: `npm run ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID }, + time: { started: 1 }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }) + +describe('useKillShell', () => { + beforeEach(() => { + vi.clearAllMocks() + api.listShells.mockResolvedValue([]) + api.removeShell.mockResolvedValue(undefined) + }) + + it('marks a killed shell terminal without erasing it', async () => { + const queryClient = createQueryClient() + queryClient.setQueryData(['opencode', 'shells', '/repo'], [shell('dev')]) + + const { result } = renderHook(() => useKillShell('/repo'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync('dev') + }) + + const [updated] = queryClient.getQueryData(['opencode', 'shells', '/repo']) ?? [] + expect(updated?.status).toBe('killed') + expect(updated?.time.completed).toBeDefined() + clearShellExitRecord('/repo', 'dev') + }) + + it('records a killed shell so a later refetch keeps it killed', async () => { + const queryClient = createQueryClient() + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [shell('dev')]) + + const { result } = renderHook( + () => ({ kill: useKillShell('/repo'), shell: useShell('dev', '/repo') }), + { wrapper: createWrapper(queryClient) }, + ) + + await act(async () => { + await result.current.kill.mutateAsync('dev') + }) + + queryClient.setQueryData(key, []) + api.listShells.mockResolvedValue([shell('dev')]) + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: key }) + }) + + const [refetched] = queryClient.getQueryData(key) ?? [] + expect(refetched?.status).toBe('killed') + clearShellExitRecord('/repo', 'dev') + }) + + it('keeps a killed shell killed through a later exit event', async () => { + const queryClient = createQueryClient() + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [shell('dev')]) + + const { result } = renderHook(() => useKillShell('/repo'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync('dev') + }) + + await act(async () => { + recordShellExit('/repo', { id: 'dev', status: 'exited', exit: 0 }) + queryClient.setQueryData(key, (current) => + current ? applyShellExit(current, { id: 'dev', status: 'exited', exit: 0 }) : current, + ) + }) + + const [after] = queryClient.getQueryData(key) ?? [] + expect(after?.status).toBe('killed') + clearShellExitRecord('/repo', 'dev') + }) +}) + +describe('useShell', () => { + beforeEach(() => { + vi.clearAllMocks() + api.listShells.mockResolvedValue([]) + api.removeShell.mockResolvedValue(undefined) + }) + + it('returns only the matching shell record once the list loads', async () => { + const queryClient = createQueryClient() + api.listShells.mockResolvedValue([shell('dev'), shell('other')]) + + const { result } = renderHook(() => useShell('dev', '/repo'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.shell?.id).toBe('dev')) + expect(result.current.listLoaded).toBe(true) + }) + + it('does not re-render when an unrelated shell record changes', async () => { + const queryClient = createQueryClient() + const dev = shell('dev') + const other = shell('other') + api.listShells.mockResolvedValue([dev, other]) + + let renderCount = 0 + const { result } = renderHook( + () => { + renderCount += 1 + return useShell('dev', '/repo') + }, + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(result.current.shell).toBe(dev)) + const rendersAfterLoad = renderCount + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [dev, { ...other, command: 'changed' }]) + }) + + expect(renderCount).toBe(rendersAfterLoad) + }) +}) diff --git a/frontend/src/hooks/useSessionShells.ts b/frontend/src/hooks/useSessionShells.ts index 6a33d4e00..a5da4979f 100644 --- a/frontend/src/hooks/useSessionShells.ts +++ b/frontend/src/hooks/useSessionShells.ts @@ -1,16 +1,20 @@ import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { listShells, removeShell } from '@/api/opencode' -import { reconcileShellList, type ShellRecord } from '@/lib/backgroundWork' +import { applyShellExit, recordShellExit, reconcileShellList, type ShellRecord } from '@/lib/backgroundWork' import { shellsQueryKey } from '@/lib/queryInvalidation' import { showToast } from '@/lib/toast' -function useShellListQuery(directory: string | undefined, enabled: boolean) { +function useShellListQuery( + directory: string | undefined, + enabled: boolean, + select?: (shells: ShellRecord[]) => TData, +) { const queryClient = useQueryClient() - return useQuery({ + return useQuery({ queryKey: shellsQueryKey(directory), - queryFn: async () => { + queryFn: async (): Promise => { const fetchStartedAt = Date.now() const fetched = await listShells(directory ?? '') const existing = queryClient.getQueryData(shellsQueryKey(directory)) ?? [] @@ -18,6 +22,7 @@ function useShellListQuery(directory: string | undefined, enabled: boolean) { }, enabled, staleTime: Infinity, + select, }) } @@ -35,16 +40,15 @@ export function useSessionShells(sessionID: string | undefined, directory: strin } export function useShell(shellID: string | undefined, directory: string | undefined) { - const query = useShellListQuery(directory, Boolean(directory && shellID)) - - const shell = useMemo( - () => (query.data ?? []).find((candidate) => candidate.id === shellID), - [query.data, shellID], + const query = useShellListQuery( + directory, + Boolean(directory && shellID), + (shells) => shells.find((candidate) => candidate.id === shellID), ) const listLoaded = query.isSuccess - return { shell, listLoaded } + return { shell: query.data, listLoaded } } export function useKillShell(directory: string | undefined) { @@ -53,12 +57,9 @@ export function useKillShell(directory: string | undefined) { return useMutation({ mutationFn: (id: string) => removeShell(id, directory ?? ''), onSuccess: (_result, id) => { + recordShellExit(directory ?? '', { id, status: 'killed' }) queryClient.setQueryData(shellsQueryKey(directory), (current) => - current?.map((shell) => - shell.id === id - ? { ...shell, status: 'killed', time: { ...shell.time, completed: shell.time.completed ?? Date.now() } } - : shell, - ), + current && applyShellExit(current, { id, status: 'killed' }), ) }, onError: (error) => { diff --git a/frontend/src/lib/backgroundWork.test.ts b/frontend/src/lib/backgroundWork.test.ts index 734227e8e..5b7bdf2ea 100644 --- a/frontend/src/lib/backgroundWork.test.ts +++ b/frontend/src/lib/backgroundWork.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest' +import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' import { applyShellExit, childLifecycle, clearShellExitRecord, + collectBackgroundParts, isRunningLifecycle, markShellDeleted, reconcileShellList, recordShellDeleted, recordShellExit, + shellBackgroundTasks, shellLifecycle, shellToolLifecycle, subagentLifecycle, @@ -15,6 +18,19 @@ import { type ShellRecord, } from './backgroundWork' +const shellNotice = ( + id: string, + shellID: string, + state: 'completed' | 'error', + exit?: number, +): SessionMessageInfo => ({ + id, + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID, state, ...(exit === undefined ? {} : { exit }) }, + time: { created: 1 }, +}) + const shell = (id: string, status: ShellRecord['status'], exit?: number, started = 1): ShellRecord => ({ id, status, @@ -106,6 +122,67 @@ describe('shellToolLifecycle', () => { expect(shellToolLifecycle('sh_1', undefined, false)).toBe('unknown') expect(shellToolLifecycle('sh_1', undefined, true)).toBe('unavailable') }) + + it('falls back to the transcript notice outcome when the shell is absent', () => { + expect(shellToolLifecycle('sh_1', undefined, true, 'completed')).toBe('completed') + expect(shellToolLifecycle('sh_1', undefined, true, 'failed')).toBe('failed') + }) + + it('prefers the live shell record over the transcript notice outcome', () => { + expect(shellToolLifecycle('sh_1', shell('sh_1', 'running'), true, 'failed')).toBe('running') + }) +}) + +describe('collectBackgroundParts', () => { + it('collects shell completion notices by shell ID', () => { + const collected = collectBackgroundParts([ + shellNotice('syn-1', 'sh_1', 'completed', 0), + shellNotice('syn-2', 'sh_2', 'error'), + shellNotice('syn-3', 'sh_3', 'completed'), + ]) + + expect(collected.shellNotices.get('sh_1')).toBe('completed') + expect(collected.shellNotices.get('sh_2')).toBe('failed') + expect(collected.shellNotices.get('sh_3')).toBe('completed') + }) + + it('ignores notices without a shell ID', () => { + const collected = collectBackgroundParts([ + { + id: 'syn-1', + type: 'synthetic', + text: '', + metadata: { source: 'shell', state: 'completed' }, + time: { created: 1 }, + }, + ]) + + expect(collected.shellNotices.size).toBe(0) + }) +}) + +describe('shellBackgroundTasks', () => { + it('uses a transcript notice when the shell has no live record', () => { + const tasks = shellBackgroundTasks( + [], + [{ id: 'sh_1', label: 'npm run dev' }], + new Map([['sh_1', 'completed']]), + true, + ) + + expect(tasks[0]?.status).toBe('completed') + }) + + it('uses a failed transcript notice when the shell has no live record', () => { + const tasks = shellBackgroundTasks( + [], + [{ id: 'sh_1', label: 'npm run dev' }], + new Map([['sh_1', 'failed']]), + true, + ) + + expect(tasks[0]?.status).toBe('failed') + }) }) describe('reconcileShellList', () => { @@ -173,6 +250,55 @@ describe('reconcileShellList', () => { clearShellExitRecord('/other', 'a') }) + + it('prunes a shell exit record once a later fetch confirms the terminal status', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'exited', 0)], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) + + it('keeps a shell exit record when the fetch started before it was written', () => { + const fetchStartedAt = Date.now() - 1000 + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'exited', 0)], fetchStartedAt, '/repo') + + expect(result?.status).toBe('exited') + + const [afterStaleFetch] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterStaleFetch?.status).toBe('exited') + + clearShellExitRecord('/repo', 'a') + }) + + it('prunes a shell exit record once a later fetch omits a terminal shell', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + const cached = shell('a', 'exited', 0, 50) + + const [result] = reconcileShellList([cached], [], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) + + it('prunes a shell exit record once a later fetch omits the shell it marked', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([shell('a', 'running', undefined, 50)], [], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) }) describe('upsertShell', () => { @@ -188,6 +314,12 @@ describe('upsertShell', () => { expect(upsertShell([cached], shell('a', 'running'), '/repo')).toEqual([cached]) }) + it('does not replace a terminal shell status with a different status', () => { + const cached = shell('a', 'killed') + + expect(upsertShell([cached], shell('a', 'exited', 0), '/repo')).toEqual([cached]) + }) + it('does not resurrect a shell deleted before the cache was seeded', () => { recordShellDeleted('/repo', 'a') @@ -224,6 +356,12 @@ describe('applyShellExit', () => { expect(result?.status).toBe('unavailable') }) + it('does not replace a killed shell with a later exit event', () => { + const [result] = applyShellExit([shell('a', 'killed')], { id: 'a', status: 'exited', exit: 0 }) + + expect(result?.status).toBe('killed') + }) + it('leaves unrelated shells untouched', () => { const other = shell('b', 'running') const [result] = applyShellExit([other], { id: 'a', status: 'killed' }) diff --git a/frontend/src/lib/backgroundWork.ts b/frontend/src/lib/backgroundWork.ts index b93436c8a..d07aedd86 100644 --- a/frontend/src/lib/backgroundWork.ts +++ b/frontend/src/lib/backgroundWork.ts @@ -6,10 +6,11 @@ import type { SessionStatusType } from '@/stores/sessionStatusStore' export const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) export type ChildOutcome = 'succeeded' | 'failed' | 'interrupted' -export type ShellLifecycleStatus = ShellInfo['status'] | 'unavailable' +export type ShellNoticeOutcome = 'completed' | 'failed' +type ShellLifecycleStatus = ShellInfo['status'] | 'unavailable' export type ShellRecord = Omit & { status: ShellLifecycleStatus } -export type BackgroundTaskKind = 'shell' | 'subagent' -export type BackgroundTaskStatus = +type BackgroundTaskKind = 'shell' | 'subagent' +type BackgroundTaskStatus = | 'running' | 'completed' | 'failed' @@ -74,6 +75,10 @@ function metadataString(part: SessionMessageAssistantTool, key: string): string return typeof value === 'string' ? value : undefined } +export function subagentSessionID(part: SessionMessageAssistantTool): string | undefined { + return metadataString(part, 'sessionID') +} + function isBackgroundPart(part: SessionMessageAssistantTool): boolean { if (part.state.status !== 'completed') return false const metadata = toolMetadata(part) @@ -87,10 +92,10 @@ export function backgroundShellID(part: SessionMessageAssistantTool): string | u export function backgroundChildSessionID(part: SessionMessageAssistantTool): string | undefined { if (part.name !== 'subagent' || !isBackgroundPart(part)) return undefined - return metadataString(part, 'sessionID') + return subagentSessionID(part) } -export function isTerminalShell(shell: { status: ShellLifecycleStatus }): boolean { +function isTerminalShell(shell: { status: ShellLifecycleStatus }): boolean { return shell.status !== 'running' } @@ -131,9 +136,11 @@ export function shellToolLifecycle( shellID: string | undefined, shell: ShellRecord | undefined, listLoaded: boolean, + notice?: ShellNoticeOutcome, ): BackgroundTaskLifecycle { if (!shellID) return 'completed' if (shell) return shellLifecycle(shell) + if (notice) return notice return listLoaded ? 'unavailable' : 'unknown' } @@ -141,6 +148,13 @@ export function isRunningLifecycle(status: BackgroundTaskLifecycle): boolean { return status === 'running' || status === 'unknown' } +export function backgroundTaskStatusColor(status: BackgroundTaskLifecycle): string { + if (status === 'completed') return 'text-success' + if (status === 'failed') return 'text-destructive' + if (status === 'unavailable') return 'text-muted-foreground' + return 'text-warning' +} + export function lifecycleLabel(status: BackgroundTaskLifecycle): string { return status === 'unknown' ? 'running' : status } @@ -162,6 +176,16 @@ function markShellUnavailable(shell: ShellRecord): ShellRecord { } } +function clearConfirmedShellExitRecord( + directory: string, + id: string, + record: ShellExitRecord | undefined, + fetchStartedAt: number, +): void { + if (!record || record.completedAt > fetchStartedAt) return + clearShellExitRecord(directory, id) +} + export function reconcileShellList( existing: ShellRecord[], fetched: ShellInfo[], @@ -174,6 +198,10 @@ export function reconcileShellList( const cached = existingByID.get(shell.id) if (cached && isTerminalShell(cached) && !isTerminalShell(shell)) return cached const record = shellExitRecord(directory, shell.id) + if (isTerminalShell(shell)) { + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) + return shell + } return record ? applyShellExitRecord(shell, record) : shell }) @@ -183,12 +211,18 @@ export function reconcileShellList( merged.push(shell) continue } + const record = shellExitRecord(directory, shell.id) if (isTerminalShell(shell)) { + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) merged.push(shell) continue } - const record = shellExitRecord(directory, shell.id) - merged.push(record ? applyShellExitRecord(shell, record) : markShellUnavailable(shell)) + if (record) { + merged.push(applyShellExitRecord(shell, record)) + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) + continue + } + merged.push(markShellUnavailable(shell)) } return merged.sort((left, right) => left.time.started - right.time.started) @@ -196,7 +230,7 @@ export function reconcileShellList( export function upsertShell(current: ShellRecord[], info: ShellInfo, directory: string): ShellRecord[] { const cached = current.find((shell) => shell.id === info.id) - if (cached && isTerminalShell(cached) && !isTerminalShell(info)) return current + if (cached && isTerminalShell(cached) && cached.status !== info.status) return current const record = shellExitRecord(directory, info.id) const next = record && !isTerminalShell(info) ? applyShellExitRecord(info, record) : info return [...current.filter((shell) => shell.id !== info.id), next] @@ -207,7 +241,8 @@ export function applyShellExit( exit: { id: string; status: ShellInfo['status']; exit?: number }, ): ShellRecord[] { return current.map((shell) => { - if (shell.id !== exit.id || shell.status === 'unavailable') return shell + if (shell.id !== exit.id) return shell + if (isTerminalShell(shell) && shell.status !== exit.status) return shell const terminal = exit.status !== 'running' return { ...shell, @@ -225,6 +260,34 @@ export function markShellDeleted(current: ShellRecord[], id: string): ShellRecor }) } +interface BackgroundShellPart { + id: string + label: string +} + +interface BackgroundSubagentPart { + childSessionID: string + label: string + toolStatus: SessionMessageAssistantTool['state']['status'] +} + +interface CollectedBackgroundParts { + shells: BackgroundShellPart[] + subagents: BackgroundSubagentPart[] + shellNotices: Map +} + +function shellNoticeFromMessage(message: SessionMessageInfo): { shellID: string; outcome: ShellNoticeOutcome } | undefined { + if (message.type !== 'synthetic' && message.type !== 'system') return undefined + const metadata = message.metadata + if (!metadata || metadata.source !== 'shell') return undefined + const shellID = metadata.shellID + if (typeof shellID !== 'string' || !shellID) return undefined + const exit = metadata.exit + const completed = metadata.state === 'completed' && (exit === undefined || exit === 0) + return { shellID, outcome: completed ? 'completed' : 'failed' } +} + function shellLabel(part: SessionMessageAssistantTool): string { if (part.state.status === 'streaming') return 'Shell command' const command = part.state.input.command @@ -232,9 +295,51 @@ function shellLabel(part: SessionMessageAssistantTool): string { return unwrapSandboxExecCommand(command) } +function subagentLabel(part: SessionMessageAssistantTool): string { + if (part.state.status !== 'streaming') { + const description = part.state.input.description + if (typeof description === 'string' && description) return description + } + return 'Sub-agent task' +} + +export function collectBackgroundParts(messages: SessionMessageInfo[]): CollectedBackgroundParts { + const shells = new Map() + const subagents = new Map() + const shellNotices = new Map() + for (const message of messages) { + const notice = shellNoticeFromMessage(message) + if (notice) { + if (!shellNotices.has(notice.shellID)) shellNotices.set(notice.shellID, notice.outcome) + continue + } + if (message.type !== 'assistant') continue + for (const part of message.content) { + if (part.type !== 'tool') continue + if (part.name === 'shell') { + const shellID = backgroundShellID(part) + if (!shellID || shells.has(shellID)) continue + shells.set(shellID, { id: shellID, label: shellLabel(part) }) + continue + } + if (part.name === 'subagent') { + const childSessionID = backgroundChildSessionID(part) + if (!childSessionID) continue + subagents.set(childSessionID, { + childSessionID, + label: subagentLabel(part), + toolStatus: part.state.status, + }) + } + } + } + return { shells: [...shells.values()], subagents: [...subagents.values()], shellNotices } +} + export function shellBackgroundTasks( shells: ShellRecord[], - messages: SessionMessageInfo[], + shellParts: BackgroundShellPart[], + notices: ReadonlyMap, listLoaded: boolean, ): BackgroundTask[] { const tasks = new Map() @@ -243,66 +348,33 @@ export function shellBackgroundTasks( id: shell.id, kind: 'shell', label: shell.command, - status: shellLifecycle(shell), + status: shellToolLifecycle(shell.id, shell, listLoaded, notices.get(shell.id)), shell, }) } - for (const message of messages) { - if (message.type !== 'assistant') continue - for (const part of message.content) { - if (part.type !== 'tool' || part.name !== 'shell') continue - const shellID = backgroundShellID(part) - if (!shellID || tasks.has(shellID)) continue - tasks.set(shellID, { - id: shellID, - kind: 'shell', - label: shellLabel(part), - status: listLoaded ? 'unavailable' : 'unknown', - }) - } + for (const part of shellParts) { + if (tasks.has(part.id)) continue + tasks.set(part.id, { + id: part.id, + kind: 'shell', + label: part.label, + status: shellToolLifecycle(part.id, undefined, listLoaded, notices.get(part.id)), + }) } return [...tasks.values()] } -function subagentLabel(part: SessionMessageAssistantTool): string { - if (part.state.status !== 'streaming') { - const description = part.state.input.description - if (typeof description === 'string' && description) return description - } - return 'Sub-agent task' -} - export function subagentBackgroundTasks( - messages: SessionMessageInfo[], - statuses: Map, - knownSessions: Set, - outcomes: Map, + parts: BackgroundSubagentPart[], + lifecycles: Record, ): BackgroundTask[] { - const tasks = new Map() - for (const message of messages) { - if (message.type !== 'assistant') continue - for (const part of message.content) { - if (part.type !== 'tool' || part.name !== 'subagent') continue - const childSessionID = backgroundChildSessionID(part) - if (!childSessionID) continue - tasks.set(childSessionID, { - id: childSessionID, - kind: 'subagent', - label: subagentLabel(part), - status: subagentLifecycle( - part.state.status, - true, - childLifecycle( - statuses.get(childSessionID) ?? { type: 'idle' }, - knownSessions.has(childSessionID), - outcomes.get(childSessionID), - ), - ), - childSessionID, - }) - } - } - return [...tasks.values()] + return parts.map((part) => ({ + id: part.childSessionID, + kind: 'subagent', + label: part.label, + status: subagentLifecycle(part.toolStatus, true, lifecycles[part.childSessionID] ?? 'unknown'), + childSessionID: part.childSessionID, + })) } diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index 4360ea06e..8ebb8eefe 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -9,11 +9,21 @@ export function shellsQueryKey(directory: string | null | undefined) { return ['opencode', 'shells', directory] } +export function invalidateShellCaches(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) +} + export function childSessionReconciliationQueryKey(sessionID: string | null | undefined) { return ['opencode', 'session-reconcile', sessionID] } -export function invalidateChildSessionCaches(queryClient: QueryClient) { +export function invalidateChildSessionCaches(queryClient: QueryClient, sessionIDs?: string[]) { + if (sessionIDs) { + for (const sessionID of sessionIDs) { + queryClient.invalidateQueries({ queryKey: childSessionReconciliationQueryKey(sessionID) }) + } + return + } queryClient.invalidateQueries({ queryKey: ['opencode', 'session-reconcile'] }) } diff --git a/frontend/src/stores/sessionStatusStore.test.ts b/frontend/src/stores/sessionStatusStore.test.ts index 050b546cd..36590561f 100644 --- a/frontend/src/stores/sessionStatusStore.test.ts +++ b/frontend/src/stores/sessionStatusStore.test.ts @@ -70,17 +70,17 @@ describe('sessionStatusStore', () => { const after = useSessionStatus.getState() expect(after.statuses).toBe(before.statuses) expect(after.getStatus('untracked')).toEqual({ type: 'idle' }) - expect(after.isSessionKnown('untracked')).toBe(true) + expect(after.knownSessions.has('untracked')).toBe(true) }) it('keeps knowledge of a session after it goes idle', () => { const store = useSessionStatus.getState() store.setStatus('session-a', { type: 'busy' }) - expect(store.isSessionKnown('session-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) store.setStatus('session-a', { type: 'idle' }) - expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) }) @@ -90,8 +90,8 @@ describe('sessionStatusStore', () => { useSessionStatus.getState().replaceStatuses({ 'session-a': { type: 'busy' } }, token) - expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(true) - expect(useSessionStatus.getState().isSessionKnown('session-unknown')).toBe(false) + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('session-unknown')).toBe(false) }) it('does not treat a session omitted from a later snapshot as finished', () => { @@ -101,7 +101,7 @@ describe('sessionStatusStore', () => { const token = store.beginStatusSnapshot() useSessionStatus.getState().replaceStatuses({}, token) - expect(useSessionStatus.getState().isSessionKnown('session-a')).toBe(false) + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(false) expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) }) @@ -112,8 +112,8 @@ describe('sessionStatusStore', () => { store.setStatus('child-a', { type: 'idle' }) - expect(useSessionStatus.getState().getOutcome('child-a')).toBe('failed') - expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + expect(useSessionStatus.getState().outcomes.get('child-a')).toBe('failed') + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) }) it('clears a child outcome when a new execution starts', () => { @@ -122,7 +122,7 @@ describe('sessionStatusStore', () => { store.clearOutcome('child-a') - expect(useSessionStatus.getState().getOutcome('child-a')).toBeUndefined() + expect(useSessionStatus.getState().outcomes.get('child-a')).toBeUndefined() }) it('applies an authoritative idle session snapshot from the child session query', () => { @@ -139,9 +139,9 @@ describe('sessionStatusStore', () => { location: { directory: '/repo' }, }, token) - expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'idle' }) - expect(useSessionStatus.getState().getOutcome('child-a')).toBe('failed') + expect(useSessionStatus.getState().outcomes.get('child-a')).toBe('failed') }) it('applies a running child session snapshot as busy', () => { @@ -157,7 +157,7 @@ describe('sessionStatusStore', () => { location: { directory: '/repo' }, }, token) - expect(useSessionStatus.getState().isSessionKnown('child-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) }) @@ -192,7 +192,7 @@ describe('sessionStatusStore', () => { location: { directory: '/repo' }, }, token) - expect(useSessionStatus.getState().getOutcome('child-a')).toBeUndefined() + expect(useSessionStatus.getState().outcomes.get('child-a')).toBeUndefined() expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) }) @@ -203,7 +203,7 @@ describe('sessionStatusStore', () => { const token = store.beginStatusSnapshot() useSessionStatus.getState().replaceStatuses({}, token) - expect(useSessionStatus.getState().isSessionKnown('child-idle')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-idle')).toBe(true) expect(useSessionStatus.getState().getStatus('child-idle')).toEqual({ type: 'idle' }) }) @@ -214,7 +214,7 @@ describe('sessionStatusStore', () => { const token = store.beginStatusSnapshot() useSessionStatus.getState().replaceStatuses({}, token) - expect(useSessionStatus.getState().isSessionKnown('child-active')).toBe(false) + expect(useSessionStatus.getState().knownSessions.has('child-active')).toBe(false) }) it('does not let a snapshot erase an idle event received in flight', () => { @@ -226,7 +226,7 @@ describe('sessionStatusStore', () => { useSessionStatus.getState().replaceStatuses({}, token) - expect(useSessionStatus.getState().isSessionKnown('child-active')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-active')).toBe(true) expect(useSessionStatus.getState().getStatus('child-active')).toEqual({ type: 'idle' }) }) @@ -410,4 +410,40 @@ describe('sessionStatusStore', () => { expect(useSessionStatus.getState().getStatus('active')).toEqual({ type: 'busy' }) }) + + it('forgets a session status, knowledge, and outcome', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + store.setOutcome('child-a', 'failed') + + store.forgetSession('child-a') + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-a')).toBe(false) + expect(after.statusCache.has('child-a')).toBe(false) + expect(after.knownSessions.has('child-a')).toBe(false) + expect(after.outcomes.has('child-a')).toBe(false) + }) + + it('does not let an in-flight snapshot resurrect a forgotten session', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + store.forgetSession('child-a') + + useSessionStatus.getState().applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-a')).toBe(false) + expect(after.knownSessions.has('child-a')).toBe(false) + expect(after.outcomes.has('child-a')).toBe(false) + }) }) diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts index 32a5ea042..6cb520224 100644 --- a/frontend/src/stores/sessionStatusStore.ts +++ b/frontend/src/stores/sessionStatusStore.ts @@ -8,7 +8,7 @@ export type SessionStatusType = | { type: 'compact' } | { type: 'retry'; attempt: number; message: string; next: number } -export interface StatusSnapshotToken { +interface StatusSnapshotToken { revision: number order: number } @@ -29,9 +29,8 @@ interface SessionStatusStore { beginStatusSnapshot: () => StatusSnapshotToken endStatusSnapshot: (token: StatusSnapshotToken) => void getStatus: (sessionID: string) => SessionStatusType - getOutcome: (sessionID: string) => ChildOutcome | undefined - isSessionKnown: (sessionID: string) => boolean clearStatus: (sessionID: string) => void + forgetSession: (sessionID: string) => void } const DEFAULT_STATUS: SessionStatusType = { type: 'idle' } @@ -319,14 +318,6 @@ export const useSessionStatus = create((set, get) => { return get().statuses.get(sessionID) || DEFAULT_STATUS }, - getOutcome: (sessionID: string) => { - return get().outcomes.get(sessionID) - }, - - isSessionKnown: (sessionID: string) => { - return get().knownSessions.has(sessionID) - }, - clearStatus: (sessionID: string) => { clearOptimisticActiveTimer(sessionID) const knownPatch = markSessionKnown(get().knownSessions, sessionID) @@ -349,6 +340,35 @@ export const useSessionStatus = create((set, get) => { return { statuses, statusCache, ...(revisionPatch ?? {}), ...knownFields } }) }, + + forgetSession: (sessionID: string) => { + clearOptimisticActiveTimer(sessionID) + const current = get() + const hasSession = + current.statuses.has(sessionID) || + current.statusCache.has(sessionID) || + current.knownSessions.has(sessionID) || + current.outcomes.has(sessionID) + if (!hasSession && inFlightSnapshots.size === 0) return + + set((state) => { + const statuses = new Map(state.statuses) + const statusCache = new Map(state.statusCache) + const knownSessions = new Set(state.knownSessions) + const outcomes = new Map(state.outcomes) + statuses.delete(sessionID) + statusCache.delete(sessionID) + knownSessions.delete(sessionID) + outcomes.delete(sessionID) + return { + statuses, + statusCache, + knownSessions, + outcomes, + ...(recordRevisionPatch(state, sessionID) ?? {}), + } + }) + }, } }) @@ -358,10 +378,6 @@ export const useSessionStatusForSession = (sessionID: string | undefined): Sessi ) } -export const useIsSessionKnown = (sessionID: string | undefined): boolean => { - return useSessionStatus((state) => (sessionID ? state.knownSessions.has(sessionID) : false)) -} - export const useChildLifecycleForSession = (sessionID: string | undefined): BackgroundTaskLifecycle => { return useSessionStatus((state) => { if (!sessionID) return 'unknown' diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index 2bdea9c06..2baf7d94a 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -48,7 +48,7 @@ export { ClientError, isIntegrationNotFoundError, isMcpServerNotFoundError, isSe export { assistantText, sessionIDFromEvent, toolContentText } from './content' -export { formatOpenCodeModelRef, parseOpenCodeModelRef } from './modelRef' +export { formatOpenCodeModelRef, parseOpenCodeModelRef, selectConfiguredModelRef } from './modelRef' export { MCP_OAUTH_CALLBACK_PATH, diff --git a/shared/src/opencode/modelRef.ts b/shared/src/opencode/modelRef.ts index 397c10c11..2d7f89952 100644 --- a/shared/src/opencode/modelRef.ts +++ b/shared/src/opencode/modelRef.ts @@ -1,4 +1,6 @@ -import type { ModelRef } from '@opencode/client' +import type { ConfigEntry, ModelRef } from '@opencode/client' + +type ConfigDocumentModel = Extract['info']['model'] export function parseOpenCodeModelRef(model: string): ModelRef | undefined { const providerEnd = model.indexOf('/') @@ -19,3 +21,19 @@ export function parseOpenCodeModelRef(model: string): ModelRef | undefined { export function formatOpenCodeModelRef(ref: ModelRef): string { return ref.variant ? `${ref.providerID}/${ref.id}#${ref.variant}` : `${ref.providerID}/${ref.id}` } + +export function selectConfiguredModelRef(entries: ConfigEntry[]): ModelRef | undefined { + const model = entries.reduce( + (current, entry) => (entry.type === 'document' && entry.info.model ? entry.info.model : current), + undefined, + ) + + if (!model) return undefined + if (typeof model === 'string') return parseOpenCodeModelRef(model) + + return { + providerID: model.providerID, + id: model.model, + ...(model.variant ? { variant: model.variant } : {}), + } +} From deab1b5877c5a2131c815a8c03eb889fed5c3690 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:49:21 -0400 Subject: [PATCH 3/3] fix: hide finished background tasks and clip long dialog titles --- .../session/BackgroundWorkBar.test.tsx | 120 +++++++----------- .../components/session/BackgroundWorkBar.tsx | 2 +- frontend/src/components/ui/dialog.test.tsx | 15 +++ frontend/src/components/ui/dialog.tsx | 2 +- 4 files changed, 65 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/session/BackgroundWorkBar.test.tsx b/frontend/src/components/session/BackgroundWorkBar.test.tsx index 2de3e50bb..5dcef7ac1 100644 --- a/frontend/src/components/session/BackgroundWorkBar.test.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.test.tsx @@ -168,25 +168,24 @@ describe('BackgroundWorkBar', () => { expect(screen.queryByRole('button', { name: /Move to background/ })).not.toBeInTheDocument() }) - it('keeps this session\'s completed shells visible and marks a killed shell', async () => { + it('hides completed shells and removes a killed shell', async () => { api.listShells.mockResolvedValue([shell('dev'), shell('other', 'session-2'), shell('done', 'session-1', 'exited')]) - renderBar([], false) + const { container } = renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /2 background tasks$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) expect(screen.getByText('npm run dev')).toBeInTheDocument() - expect(screen.getByText('npm run done')).toBeInTheDocument() + expect(screen.queryByText('npm run done')).not.toBeInTheDocument() expect(screen.queryByText('npm run other')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Kill npm run dev' })) await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) - expect(await screen.findByText('killed')).toBeInTheDocument() - expect(screen.getByText('npm run dev')).toBeInTheDocument() + await waitFor(() => expect(container).toBeEmptyDOMElement()) clearShellExitRecord('/repo', 'dev') }) - it('updates a shell row from running to completed without removing it', async () => { + it('removes a shell row once it completes', async () => { api.listShells.mockResolvedValue([shell('dev')]) const { queryClient } = renderBar([], false) @@ -199,8 +198,8 @@ describe('BackgroundWorkBar', () => { ]) }) - expect(await screen.findByText('completed')).toBeInTheDocument() - expect(screen.getByText('npm run dev')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('npm run dev')).not.toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /background task/ })).not.toBeInTheDocument() }) it('preserves a shell created while a stale list fetch was in flight', async () => { @@ -223,9 +222,9 @@ describe('BackgroundWorkBar', () => { expect(screen.queryByText('unavailable')).not.toBeInTheDocument() }) - it('marks a running shell omitted from a fresh list as unavailable', async () => { + it('hides a running shell omitted from a fresh list once it becomes unavailable', async () => { api.listShells.mockResolvedValue([]) - const { queryClient } = renderBar([], false) + const { queryClient, container } = renderBar([], false) await waitFor(() => expect(api.listShells).toHaveBeenCalled()) await act(async () => { @@ -233,9 +232,7 @@ describe('BackgroundWorkBar', () => { await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) }) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - expect(await screen.findByText('unavailable')).toBeInTheDocument() - expect(screen.queryByText('running')).not.toBeInTheDocument() + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) it('does not resurrect a completed shell from a stale list fetch', async () => { @@ -253,11 +250,11 @@ describe('BackgroundWorkBar', () => { await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) }) - await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) - expect(screen.getByText('npm run dev')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('npm run dev')).not.toBeInTheDocument()) + expect(screen.queryByText('running')).not.toBeInTheDocument() }) - it('shows a backgrounded subagent and completes it when the child goes idle', async () => { + it('shows a backgrounded subagent and removes it when the child goes idle', async () => { renderBar([backgroundSubagentTool('child-1')], false) fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) @@ -273,8 +270,7 @@ describe('BackgroundWorkBar', () => { useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) }) - expect(await screen.findByText('completed')).toBeInTheDocument() - expect(screen.getByText('Explore')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('Explore')).not.toBeInTheDocument()) }) it('treats an unknown child session as still running', async () => { @@ -296,43 +292,35 @@ describe('BackgroundWorkBar', () => { time: { created: 1, updated: 2, idle: 2 }, location: { directory: '/repo' }, }) - renderBar([backgroundSubagentTool('child-1')], false) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - - await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) - expect(screen.queryByText('running')).not.toBeInTheDocument() + await waitFor(() => expect(api.getSession).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('shows a failed child session outcome as failed', async () => { + it('hides a failed child session outcome', async () => { act(() => { useSessionStatus.getState().setOutcome('child-1', 'failed') useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) }) - renderBar([backgroundSubagentTool('child-1')], false) - - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) - expect(await screen.findByText('failed')).toBeInTheDocument() - expect(screen.queryByText('completed')).not.toBeInTheDocument() + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('shows an interrupted child session outcome as interrupted', async () => { + it('hides an interrupted child session outcome', async () => { act(() => { useSessionStatus.getState().setOutcome('child-1', 'interrupted') useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) }) - renderBar([backgroundSubagentTool('child-1')], false) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - - expect(await screen.findByText('interrupted')).toBeInTheDocument() - expect(screen.queryByText('completed')).not.toBeInTheDocument() + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('keeps a deleted shell visible as unavailable', async () => { + it('hides a deleted shell once it becomes unavailable', async () => { api.listShells.mockResolvedValue([shell('dev')]) - const { queryClient } = renderBar([], false) + const { queryClient, container } = renderBar([], false) fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) @@ -342,46 +330,37 @@ describe('BackgroundWorkBar', () => { ]) }) - expect(await screen.findByText('unavailable')).toBeInTheDocument() - expect(screen.getByText('npm run dev')).toBeInTheDocument() - expect(screen.queryByText('running')).not.toBeInTheDocument() + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('shows a historical shell absent from the fetched list as unavailable', async () => { + it('hides a historical shell absent from the fetched list', async () => { api.listShells.mockResolvedValue([]) - renderBar([backgroundShellTool('sh-history', 'npm run history')], false) - - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + const { container } = renderBar([backgroundShellTool('sh-history', 'npm run history')], false) - expect(await screen.findByText('unavailable')).toBeInTheDocument() - expect(screen.getByText('npm run history')).toBeInTheDocument() - expect(screen.queryByText('running')).not.toBeInTheDocument() + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('shows a historical shell absent from the list as completed from its notice', async () => { + it('hides a historical shell absent from the list once its notice reports completion', async () => { api.listShells.mockResolvedValue([]) - renderBar( + const { container } = renderBar( [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'completed', 0)], false, ) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - - expect(await screen.findByText('completed')).toBeInTheDocument() - expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) - it('shows a historical shell absent from the list as failed from its error notice', async () => { + it('hides a historical shell absent from the list once its error notice reports failure', async () => { api.listShells.mockResolvedValue([]) - renderBar( + const { container } = renderBar( [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'error')], false, ) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - - expect(await screen.findByText('failed')).toBeInTheDocument() - expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) }) it('keeps a historical shell unknown while its list is still loading', async () => { @@ -396,18 +375,16 @@ describe('BackgroundWorkBar', () => { it('uses an already cached shell list when the bar remounts', async () => { const messages = [backgroundShellTool('sh-history', 'npm run history')] - const { queryClient, unmount } = renderBar(messages, false) - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) - expect(await screen.findByText('unavailable')).toBeInTheDocument() + const { queryClient, unmount, container } = renderBar(messages, false) + await waitFor(() => expect(container).toBeEmptyDOMElement()) unmount() - render( + const remount = render( , ) - fireEvent.click(screen.getByRole('button', { name: /1 background task$/ })) - expect(screen.getByText('unavailable')).toBeInTheDocument() + await waitFor(() => expect(remount.container).toBeEmptyDOMElement()) expect(api.listShells).toHaveBeenCalledTimes(1) }) @@ -421,7 +398,7 @@ describe('BackgroundWorkBar', () => { act(() => { useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) }) - expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('Explore')).not.toBeInTheDocument() await act(async () => { resolveSession?.({ @@ -434,18 +411,17 @@ describe('BackgroundWorkBar', () => { }) }) - expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('Explore')).not.toBeInTheDocument() expect(screen.queryByText('running')).not.toBeInTheDocument() }) it('applies a shell exit recorded before the list was seeded', async () => { recordShellExit('/repo', { id: 'sh-seeded', status: 'exited', exit: 0 }) api.listShells.mockResolvedValue([shell('sh-seeded')]) - renderBar([], false) - - fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + const { container } = renderBar([], false) - expect(await screen.findByText('completed')).toBeInTheDocument() + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) clearShellExitRecord('/repo', 'sh-seeded') }) diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx index 38e8af7b2..ed229ed3b 100644 --- a/frontend/src/components/session/BackgroundWorkBar.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -220,7 +220,7 @@ export function BackgroundWorkBar({ sessionID, directory, messages, isSessionAct () => [ ...shellBackgroundTasks(shells, collected.shells, collected.shellNotices, listLoaded), ...subagentBackgroundTasks(collected.subagents, childLifecycles), - ], + ].filter((task) => isRunningLifecycle(task.status)), [shells, collected, listLoaded, childLifecycles], ) diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx index 827afc7a9..2328d94a4 100644 --- a/frontend/src/components/ui/dialog.test.tsx +++ b/frontend/src/components/ui/dialog.test.tsx @@ -383,6 +383,21 @@ describe("DialogContent", () => { }); }); +describe("DialogHeader", () => { + it("can shrink so a long title truncates instead of widening the dialog", () => { + render( + + + + a very long command that would otherwise widen the dialog + + + + ); + expect(screen.getByTestId("dialog-header")).toHaveClass("min-w-0"); + }); +}); + function stubVisualViewport(height: number) { const listeners = new Set<() => void>() Object.defineProperty(window, 'visualViewport', { diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 819dbabd9..9d4e2b70b 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -146,7 +146,7 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (