diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 56429c2b8246..7464613d5a16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -120,6 +120,8 @@ export class StructuredAgentSessionClientDelivery { publishConversationName = (sessionId: string): void => this.statusFeed.publishConversationName(sessionId) + publishRecordNotes = (sessionId: string): void => this.statusFeed.publishRecordNotes(sessionId) + publishChildWork = ( sessionId: string, evidence: AgentChildWorkEvidence[], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 774ed71b0b0e..148701e8b77c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -31,7 +31,8 @@ import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' import { stopWorkspaceSleepChat, - workspaceSleepStartAborts + workspaceSleepStartAborts, + type WorkspaceSleepCloseResult } from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { @@ -122,7 +123,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId: string, workspaceId: string, executionHostId: ExecutionHostId - ): Promise<{ fence: number; stopped: boolean } | null> => { + ): Promise => { const matches = (): boolean => { const location = deps().store.getRecord(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId @@ -143,6 +144,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { return null } const fence = record.lease.runtimeFence + const admittedSends = deps().store.admittedSendCount(sessionId) const session = sessions.get(sessionId) const child = session?.child const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true @@ -168,7 +170,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) // Lease release advances the fence; preserve the value after this close for the caller's // final identity check against a later restart. - return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } + return { + fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, + stopped, + admittedSends + } }) return starts.settle({ deps: deps(), sessions, sessionId }, await closed) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0a87b81610d3..3f4d97714f3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -18,10 +18,7 @@ import { structuredAgentSessionEntryDelegates } from './structured-agent-session import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' -import { - createStructuredAgentSessionConversationLifetime, - type StructuredAgentSessionConversationLifetime -} from './structured-agent-session-conversation-lifetime' +import * as lifetime from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { createStructuredAgentSessionTabSurface as createTabs } from './structured-agent-session-host-tabs' import { @@ -78,7 +75,7 @@ export class StructuredAgentSessionHost { private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType private readonly restore: ReturnType - private readonly lifetime: StructuredAgentSessionConversationLifetime + private readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel @@ -155,7 +152,7 @@ export class StructuredAgentSessionHost { childWorkOpen: this.clientDelivery.childWorkOpen, publishStatus: this.clientDelivery.publishStatusAndSettlement }) - this.lifetime = createStructuredAgentSessionConversationLifetime({ + this.lifetime = lifetime.createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -210,11 +207,11 @@ export class StructuredAgentSessionHost { } /** Releases a session's resources without ending the conversation; see the lifetime's close. * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ - close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => + close: lifetime.StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ - sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => + sleepWorkspace = (workspaceId: string, markAsleep?: boolean) => sleepStructuredAgentSessionWorkspace( { deps: this.deps, @@ -224,9 +221,11 @@ export class StructuredAgentSessionHost { // A start registered after the stop is newer work, as a running delivery is. deliveryRunning: (id) => this.conversationDelivery.loop.isRunning(id) || this.runtimeState.startFlights.has(id), - childWorkIncomplete: this.clientDelivery.childWorkIncomplete + childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + publishRecordNotes: this.clientDelivery.publishRecordNotes }, - workspaceId + workspaceId, + markAsleep ) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index db9b4b51f60f..78c59a80dfbb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -12,6 +12,7 @@ import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-se import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createQueuedMessageTestRig, + eventually, QUEUED_RIG_CALLER as CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -654,3 +655,28 @@ describe('source-bound ordinary Continue', () => { } }) }) + +it.each(['send-now', 'automatic'] as const)( + 'clears Sleep on queued submission only: %s', + async (mode) => { + await rig.workingSend() + const draftId = await queuedDraft('retained through Sleep') + expect(await rig.host.sleepWorkspace('workspace-1', true)).toEqual({ + stoppedSessionIds: [SESSION] + }) + const sleptAt = rig.store.getRecord(SESSION)?.sleptAt + expect(sleptAt).toEqual(expect.any(Number)) + await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(rig.store.getRecord(SESSION)?.sleptAt).toBe(sleptAt) + const release = rig.holdNextStart() + try { + expect(await (mode === 'send-now' ? rig.sendNow(draftId) : rig.resume())).toMatchObject({ + ok: true + }) + await eventually(() => expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined()) + expect(await rig.handoff(draftId)).toBeDefined() + } finally { + release() + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 7faef032f13e..fcd1a624ddbb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -37,6 +37,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' /** Waiting, not held on its own, and not positioned behind a returned card or a * card the queue's pause holds: the queue never reorders. The admission rule @@ -252,6 +253,7 @@ export type QueuedMessageDrainDeps = { currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null /** The consumed submission is ordinary #22821 work from here on. */ wakeDelivery: (sessionId: string) => void + submissionReceipt?: (sessionId: string) => JournalOperationReceipt logger: StructuredAgentSessionLogger } @@ -359,7 +361,8 @@ export class StructuredAgentSessionQueuedMessageDrain { settledByOp: null, hostInstance: structuredAgentSessionHostInstance(), yieldsToPause: true - } + }, + this.deps.submissionReceipt?.(sessionId) ) } catch (error) { if (error instanceof QueuedMessageNotConsumableError) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index a764aa613e6e..896c1d2a5619 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -40,6 +40,7 @@ export function wireStructuredAgentSessionQueuedMessages( currentWork: (sessionId) => hostStructuredAgentSessionCurrentWork({ store: context().deps.store, sessions }, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), + submissionReceipt: (sessionId) => context().deps.store.submissionReceipt(sessionId), // Read lazily, like the rest of this wiring: the host's deps are not assigned yet. logger: deferredStructuredAgentSessionLogger(() => context().deps.logger) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts index 03ea4e3b2f66..220e1b217289 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -55,7 +55,11 @@ async function namedFeed() { failure = next feed.publishConversationName('session') } - return { feed, events, sessions, name, fail } + const sleep = (sleptAt: number | undefined) => { + record = { ...record, sleptAt } + feed.publishRecordNotes('session') + } + return { feed, events, sessions, name, fail, sleep } } function lastName(events: readonly AgentSessionStatusEvent[]): string | undefined { @@ -100,19 +104,30 @@ it('publishes naming activity without needing a chat summary', async () => { const count = events.length feed.publishConversationName('unknown-session') expect(events).toHaveLength(count + 1) + expect(events.at(-1)).toEqual({ type: 'snapshot', sessions: [], namingActiveSessionIds: [] }) }) it('shows a closed chat its naming failure only while it keeps the name that failed', async () => { - const { feed, events, sessions, name, fail } = await namedFeed() + const { feed, events, sessions, name, fail, sleep } = await namedFeed() name('Explain the parser') feed.revokeLive('session') sessions.clear() + sleep(5) + expect(events.at(-1)).toMatchObject({ session: { sleptAt: 5 } }) fail({ message: 'Agent failed', failedAt: 5, forName: 'Explain the parser' }) expect(events.at(-1)).toMatchObject({ type: 'status', - session: { conversationName: 'Explain the parser', namingError: { message: 'Agent failed' } } + session: { + conversationName: 'Explain the parser', + namingError: { message: 'Agent failed' }, + sleptAt: 5 + } }) name('parser/explain') expect(events.at(-1)).toMatchObject({ session: { conversationName: 'parser/explain' } }) expect(events.at(-1)).not.toHaveProperty('session.namingError') + expect(events.at(-1)).toMatchObject({ session: { sleptAt: 5 } }) + sleep(undefined) + expect(events.at(-1)).not.toHaveProperty('session.sleptAt') + expect(lastName(events)).toBe('parser/explain') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 2b6f6e6a80c0..9a0a0753db7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -153,16 +153,17 @@ export class StructuredAgentSessionStatusFeed { firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null }) this.sink(retained) - this.broadcast({ - type: 'status', - session: retained - }) + this.broadcast({ type: 'status', session: retained }) } - /** The record's name, or its naming failure, changed outside the journal. A closed chat's - * retained row follows it too, so every list still showing it learns the name without a tab. */ + /** Naming activity is published even when the chat has no retained status row. */ publishConversationName(sessionId: string): void { this.broadcast(structuredAgentSessionNamingSnapshot(this.deps)) + this.publishRecordNotes(sessionId) + } + + /** Record annotations update held and retained rows without opening a journal. */ + publishRecordNotes(sessionId: string): void { if (this.deps.sessions.has(sessionId)) { this.publish(sessionId) return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index d779b43e9f5b..5df2cd48fe70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -37,6 +37,7 @@ export function structuredStatusSummariesEqual( a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && a.conversationName === b.conversationName && + a.sleptAt === b.sleptAt && a.namingError?.message === b.namingError?.message && a.namingError?.failedAt === b.namingError?.failedAt && a.turnOutcome === b.turnOutcome && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index daa2f2893a92..0fb961236b79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -70,6 +70,7 @@ export function structuredAgentSessionStatusSummary({ ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), ...structuredSummaryConversationNaming(record, namingFailure), + ...(typeof record?.sleptAt === 'number' ? { sleptAt: record.sleptAt } : {}), updatedAt: journal.lastActivityAt() || now() } } @@ -91,19 +92,18 @@ function structuredSummaryConversationNaming( : { conversationName: name } } -/** A closed chat's retained summary with its current naming, or null when nothing changed. */ +/** A closed chat's retained summary with current record annotations, or null if unchanged. */ export function renamedStructuredStatusSummary( previous: AgentSessionStatusSummary, deps: Pick, sessionId: string ): AgentSessionStatusSummary | null { - const { conversationName: _name, namingError: _error, ...rest } = previous + const record = deps.getRecord(sessionId) + const { conversationName: _name, namingError: _error, sleptAt: _sleptAt, ...rest } = previous const summary = { ...rest, - ...structuredSummaryConversationNaming( - deps.getRecord(sessionId), - deps.namingFailure?.(sessionId) - ) + ...structuredSummaryConversationNaming(record, deps.namingFailure?.(sessionId)), + ...(typeof record?.sleptAt === 'number' ? { sleptAt: record.sleptAt } : {}) } return structuredStatusSummariesEqual(previous, summary) ? null : summary } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 0592ed283daa..eaec898f91dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -54,7 +54,7 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { if (args[0] === sibling) { await slowSibling - return { fence: siblingRecord.lease.runtimeFence, stopped: true } + return { fence: siblingRecord.lease.runtimeFence, stopped: true, admittedSends: 0 } } const result = await close(...args) failedStopDone() @@ -173,6 +173,73 @@ it('keeps a send admitted after the host Sleep close alive', async () => { expect(host.hasSession('session-alpha')).toBe(true) }) +it('keeps Sleep through history reopen and replay, and clears it atomically with a new send', async () => { + const { host, store, dispatch, root } = hostTestState() + await attach() + const send = async (text: string) => { + const body = hostTestMessage(text) + const params = { envelope: envelope('agentSession.send', { body }), body } + expect(await host.send(CALLER, params)).toMatchObject({ ok: true }) + return params + } + // A stop that did not ask (a project move) marks nothing. + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: ['session-alpha'] }) + expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() + const previous = await send('after the move') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await host.sleepWorkspace('workspace-1') + expect(host.hasSession(SESSION)).toBe(false) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const stopped = await close(...args) + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true + }) + return stopped + }) + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + const sleptAt = store.getRecord('session-alpha')?.sleptAt + expect(sleptAt).toEqual(expect.any(Number)) + expect(await host.send(CALLER, previous)).toMatchObject({ ok: true, replayed: true }) + expect(store.getRecord('session-alpha')?.sleptAt).toBe(sleptAt) + + const outcomeReceipt = store.operationOutcomeReceipt.bind(store) + let stagedAwake = false + vi.spyOn(store, 'operationOutcomeReceipt').mockImplementationOnce((args) => { + const receipt = outcomeReceipt(args) + return { + ...receipt, + write: (db, row) => { + receipt.write(db, row) + stagedAwake = + db.isTransaction && + db + .prepare( + "SELECT json_extract(record_json, '$.sleptAt') AS sleptAt FROM agent_session_records WHERE session_id = ?" + ) + .get(SESSION)?.sleptAt === null + throw new Error('transaction failed before commit') + } + } + }) + const body = hostTestMessage('rolled back send') + const rolledBack = { envelope: envelope('agentSession.send', { body }), body } + expect(await host.send(CALLER, rolledBack)).toMatchObject({ ok: false }) + expect(stagedAwake).toBe(true) + expect((await openTestAgentSessionRecordStore(root)).getRecord(SESSION)?.sleptAt).toBe(sleptAt) + expect( + host['sessions'] + .get(SESSION) + ?.journal.submissions() + .some((send) => send.clientMessageId === rolledBack.envelope.clientOperationId) + ).toBe(false) + + await send('wake up') + expect((await openTestAgentSessionRecordStore(root)).getRecord(SESSION)?.sleptAt).toBeUndefined() +}) + it('returns the receipt for a stopped child whose provider reports its own end', async () => { const { host, store } = hostTestState() await attach() @@ -206,29 +273,41 @@ it('omits an unproven workspace child from the stop receipt', async () => { await attach() host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ stoppedSessionIds: [], failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] }) expect(store.getRecord(SESSION)).not.toBeNull() + expect(store.getRecord(SESSION)?.sleptAt).toBeUndefined() }) -it('does not count a chat whose send was accepted after the stop as stopped', async () => { - const { host } = hostTestState() +it.each(['running', 'failed'] as const)('keeps a newer %s send awake', async (state) => { + const { host, acquire, store } = hostTestState() await attach() host.deps.adapter.closeSession = vi.fn(async () => true) await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) const close = host['lifetime'].closeForWorkspaceSleep vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { const result = await close(...args) + if (state === 'failed') { + acquire.mockRejectedValueOnce(new Error('provider unavailable')) + } const body = hostTestMessage('newer send') // The later work exists once the send is accepted, not merely once it is queued. const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) expect(await sent).toMatchObject({ ok: true }) + if (state === 'failed') { + await vi.waitFor(() => { + expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false) + expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false) + expect(host['sessions'].get(SESSION)?.journal.pendingSubmissions()).toEqual([]) + }) + } return result }) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ stoppedSessionIds: [] }) + expect(store.getRecord(SESSION)?.sleptAt).toBeUndefined() }) // A start runs outside the chat's lane, so Sleep reaches it through the start's own abort. @@ -261,7 +340,7 @@ it('stops an agent that is still starting', async () => { // Before the adapter owns a process, the aborted start leaves the chat at rest: that is stopped. it('stops an agent whose process is still being acquired', async () => { - const { host, acquire } = hostTestState() + const { host, acquire, store } = hostTestState() await attach() expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) let acquiring = false @@ -277,7 +356,8 @@ it('stops an agent whose process is still being acquired', async () => { ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(acquiring).toBe(true)) - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ stoppedSessionIds: [SESSION] }) + expect(store.getRecord(SESSION)?.sleptAt).toEqual(expect.any(Number)) expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false) expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() }) @@ -291,7 +371,9 @@ it('sleeps only proven ended chats after idle eviction or restart', async () => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) expect(rig.host.hasSession(SESSION)).toBe(false) }) + expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined() expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined() await rig.restart() await rig.host.restoreReadableSessions() expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index b5c65dee63ea..a60c1cf216df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -93,6 +93,12 @@ function workspaceSleepChatAtRest( ) } +export type WorkspaceSleepCloseResult = { + fence: number + stopped: boolean + admittedSends: number +} + /** The same abort a close gives a registered start, remembering which starts it aborted so the * result is read once they settle, outside the lane their last commits need. */ export function workspaceSleepStartAborts( @@ -113,7 +119,7 @@ export function workspaceSleepStartAborts( }, settle: ( input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, - result: { fence: number; stopped: boolean } | null + result: WorkspaceSleepCloseResult | null ) => settleWorkspaceSleepAbortedStarts(input, result, aborted, () => runtimeState().startFlights.has(sessionId) @@ -125,16 +131,20 @@ export function workspaceSleepStartAborts( * the chat it left at rest counts as stopped. */ async function settleWorkspaceSleepAbortedStarts( input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, - result: { fence: number; stopped: boolean } | null, + result: WorkspaceSleepCloseResult | null, abortedStarts: readonly Promise[], startRunning: () => boolean -): Promise<{ fence: number; stopped: boolean } | null> { +): Promise { await Promise.allSettled(abortedStarts) if (!result || result.stopped || abortedStarts.length === 0 || startRunning()) { return result } const { deps, sessions, sessionId } = input return workspaceSleepChatAtRest(deps, sessions, sessionId) - ? { fence: deps.store.getRecord(sessionId)?.lease.runtimeFence ?? result.fence, stopped: true } + ? { + ...result, + fence: deps.store.getRecord(sessionId)?.lease.runtimeFence ?? result.fence, + stopped: true + } : result } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 5ebbe337fcf3..a29edd44bcb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -1,4 +1,7 @@ -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionWorkspaceSleepResult } from '../../../shared/agent-session-workspace-sleep' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -7,18 +10,23 @@ import type { StructuredAgentSessionConversations } from './structured-agent-ses import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' type WorkspaceSleepHost = { - deps: Pick + deps: Pick tasks: StructuredAgentSessionTaskQueue lifetime: Pick sessions: StructuredAgentSessionConversations deliveryRunning: (sessionId: string) => boolean childWorkIncomplete: (sessionId: string) => boolean + publishRecordNotes: (sessionId: string) => void } -/** Snapshot host-owned members and return only same-call, current positive stop receipts. */ +type WorkspaceSleepVerdict = { stopped: true } | { stopped: false; fence: number } | null + +/** Snapshot host-owned members and return only same-call, current positive stop receipts. With + * `markAsleep`, exactly the chats in the receipt carry the Sleep mark. */ export async function sleepStructuredAgentSessionWorkspace( host: WorkspaceSleepHost, - workspaceId: string + workspaceId: string, + markAsleep = false ): Promise { let records: ReturnType try { @@ -40,51 +48,89 @@ export async function sleepStructuredAgentSessionWorkspace( const sessionIds = [...candidates] // Admits every close synchronously; `async` turns a synchronous throw into a rejection. const stops = await Promise.allSettled( - sessionIds.map(async (sessionId) => - host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) - ) + sessionIds.map(async (sessionId) => { + const stop = await host.lifetime.closeForWorkspaceSleep( + sessionId, + workspaceId, + LOCAL_EXECUTION_HOST_ID + ) + return host.tasks.serialize(sessionId, async () => { + const record = host.deps.store.getRecord(sessionId) + if ( + !stop || + !record || + !belongs(record.location) || + record.lease.runtimeFence !== stop.fence || + host.deps.store.admittedSendCount(sessionId) !== stop.admittedSends + ) { + return null + } + return settleWorkspaceSleepChat(host, sessionId, stop.stopped, markAsleep) + }) + }) ) const stoppedSessionIds: string[] = [] const failures: NonNullable = [] for (const [index, stop] of stops.entries()) { const sessionId = sessionIds[index] if (stop.status === 'rejected') { - failures.push({ sessionId, reason: 'stop-unverifiable' }) - continue - } - if (!stop.value) { - continue - } - try { - const record = host.deps.store.getRecord(sessionId) - if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { - continue - } - if (!stop.value.stopped) { - failures.push({ sessionId, reason: 'stop-unverifiable' }) - continue - } - // Judge later work by facts, not lane occupancy: the stop queues its own end report. - const session = host.sessions.get(sessionId) - if ( - session?.child || - host.tasks.hasPendingAttach(sessionId) || - host.deliveryRunning(sessionId) || - host.childWorkIncomplete(sessionId) || - session?.journal.pendingSubmissions().length || - host.deps.hasOpenDispatch?.(record) - ) { - continue - } - stoppedSessionIds.push(sessionId) - } catch (error) { - host.deps.logger.warn('workspace chat stop receipt could not be checked', { + host.deps.logger.warn('workspace chat stop receipt could not be decided', { scope: 'workspace-chat-receipt', sessionId, - error + error: stop.reason }) failures.push({ sessionId, reason: 'stop-unverifiable' }) + } else if (stop.value?.stopped) { + stoppedSessionIds.push(sessionId) + } else if ( + stop.value && + // A newer start superseded the unproven stop, so there is nothing left to report. + host.deps.store.getRecord(sessionId)?.lease.runtimeFence === stop.value.fence + ) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) } } return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } } + +/** After aborted starts settle, one ordered step decides the receipt and writes its mark. */ +async function settleWorkspaceSleepChat( + host: WorkspaceSleepHost, + sessionId: string, + stopped: boolean, + markAsleep: boolean +): Promise { + const current = host.deps.store.getRecord(sessionId) + if (!current) { + return null + } + if (!stopped) { + return { stopped: false, fence: current.lease.runtimeFence } + } + // Judge later work by facts, not lane occupancy: the stop queues its own end report. + const quiet = (record: AgentSessionRecord): boolean => { + const session = host.sessions.get(sessionId) + return !( + session?.child || + host.tasks.hasPendingAttach(sessionId) || + host.deliveryRunning(sessionId) || + host.childWorkIncomplete(sessionId) || + session?.journal.pendingSubmissions().length || + host.deps.hasOpenDispatch?.(record) + ) + } + if (!markAsleep) { + return quiet(current) ? { stopped: true } : null + } + let marked = false + const now = host.deps.now?.() ?? Date.now() + // Judged inside the write, so the receipt and the mark cannot disagree. + await host.deps.store.transitionHandoff(sessionId, (record) => { + marked = quiet(record) + return marked ? { ...record, sleptAt: now, updatedAt: now } : record + }) + if (marked) { + host.publishRecordNotes(sessionId) + } + return marked ? { stopped: true } : null +} diff --git a/src/main/runtime/agent-session-operation-receipt.ts b/src/main/runtime/agent-session-operation-receipt.ts index dff02a2e0085..feda6803c05d 100644 --- a/src/main/runtime/agent-session-operation-receipt.ts +++ b/src/main/runtime/agent-session-operation-receipt.ts @@ -18,8 +18,11 @@ export function agentSessionOperationOutcomeReceipt( if ('fingerprint' in args) { // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. return transactions.receipt((draft) => insertAcceptedAgentSessionOperationInto(draft, args), { - insertOperationsIfAbsent: true + insertOperationsIfAbsent: true, + submissionSessionId: args.outcome.status === 'succeeded' ? args.outcome.sessionId : undefined }) } - return transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + return transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args), { + submissionSessionId: args.outcome.status === 'succeeded' ? args.outcome.sessionId : undefined + }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 27f13e818c8f..4e329300d864 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -241,12 +241,10 @@ export class AgentSessionRecordStore { return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) } - async transitionHandoff( + transitionHandoff = ( sessionId: string, transition: (record: AgentSessionRecord) => AgentSessionRecord - ): Promise { - return this.mutate(sessionId, transition) - } + ): Promise => this.mutate(sessionId, transition) /** * Adjudicate loaded leases, or only the addressed session. No lease grants a writer until it @@ -295,11 +293,16 @@ export class AgentSessionRecordStore { args: Parameters[1] ): Promise => this.transact((draft) => settleAgentSessionOperationInto(draft, args)) - /** That settlement, or an accepted row inserted if absent, committed by the journal write that - * makes it true. It changes only the ledger, so no record listener is owed. */ + /** Settlement committed with its journal write; a new submission also clears Sleep in that draft. */ operationOutcomeReceipt = (args: Parameters[1]) => agentSessionOperationOutcomeReceipt(this.transactions, args) + /** Automatic queue admission uses the same record transition as a client's send receipt. */ + submissionReceipt = (sessionId: string) => + this.transactions.receipt(() => {}, { submissionSessionId: sessionId }) + + admittedSendCount = (sessionId: string) => this.transactions.admittedSendCount(sessionId) + commitOperationReceipt = (receipt: JournalOperationReceipt) => this.transactions.commitReceipt(receipt) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index f68b9a1b6a82..a807521c7f32 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -89,6 +89,8 @@ type StagedStoreTransaction = { export class AgentSessionStoreTransactions { private queue: Promise = Promise.resolve() + // Reads/reopens do not admit work; a count survives handle eviction without replaying history. + private readonly admittedSends = new Map() private published: AgentSessionStoreState /** What this runtime took over from the runtimes before it, as loaded; never changes after. */ readonly replacedRuntimes: ReadonlyMap @@ -124,6 +126,8 @@ export class AgentSessionStoreTransactions { return this.journalDatabase.readOnly } + admittedSendCount = (sessionId: string): number => this.admittedSends.get(sessionId) ?? 0 + readCommandReceipt(scope: CommandReceiptScope, operationId: string) { return readCommandReceipt(this.journalDatabase.db, scope, operationId) } @@ -149,28 +153,49 @@ export class AgentSessionStoreTransactions { */ receipt( apply: (draft: AgentSessionStoreState) => void, - options: { insertOperationsIfAbsent?: true } = {} + options: { insertOperationsIfAbsent?: true; submissionSessionId?: string } = {} ): JournalOperationReceipt { let staged: StagedStoreTransaction | null = null + let admitted: string | undefined return { - write: (db) => { + write: (db, row) => { if (this.journalDatabase.readOnly) { throw readOnlyStoreRefusal() } - staged = this.stage(apply) + admitted = row?.kind === 'submission' ? options.submissionSessionId : undefined + // The ledger and wake share one draft as well as one commit. + staged = this.stage((draft) => { + apply(draft) + const record = admitted ? draft.records.get(admitted) : undefined + if (record?.sleptAt !== undefined) { + const { sleptAt: _sleptAt, ...awake } = record + draft.records.set(record.sessionId, { + ...awake, + updatedAt: row?.ts ?? record.updatedAt + }) + } + }) const writes = staged.writes if (writes) { // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. if (options.insertOperationsIfAbsent) { insertAgentSessionOperationRowsIfAbsent(db, writes.operations.upsert) - } else { - writeAgentSessionStoreRows(db, writes) } + writeAgentSessionStoreRows( + db, + options.insertOperationsIfAbsent + ? { ...writes, operations: { ...writes.operations, upsert: [] } } + : writes + ) } }, committed: () => { staged?.adopt() + if (admitted) { + this.admittedSends.set(admitted, this.admittedSendCount(admitted) + 1) + } staged = null + admitted = undefined } } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index 095f887a99d3..8181c4a157a8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -3,6 +3,7 @@ // Session lists read turn state from here instead of replaying transcripts: one stream per client // covers every session, and unlike a transcript subscription it retains none of them. +import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' @@ -48,6 +49,10 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ permission: 'workspace', params: null, handler: async (_params, ctx, emit) => { + const restoration = restoreStructuredTabsIfSupported(ctx) + if (restoration) { + await restoration + } const host = requireHost(ctx) const subscriptionId = structuredAgentSessionStatusSubscriptionId(ctx) let dispose = (): void => {} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index bc56c9c05e3b..605ec45bbf50 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -865,8 +865,15 @@ describe('agentSession.subscribeStatus', () => { }) it('opens the host status feed with a projected snapshot as its first reply', async () => { - const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT) - expect(reply).toMatchObject({ + const restoring = Promise.withResolvers() + const restore = vi.fn(() => restoring.promise) + const response = call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT, { + restoreStructuredAgentSessionTabs: restore + }) + await vi.waitFor(() => expect(restore).toHaveBeenCalledOnce()) + expect(hostCalls.subscribeStatus).not.toHaveBeenCalled() + restoring.resolve() + expect(await response).toMatchObject({ ok: true, result: { type: 'snapshot', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 98cea4948e9f..f1982ab6dd1f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -175,9 +175,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: SleepWorkspaceParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) + if (params.markAsleep) { + return (await requireInstalledHost(ctx)).sleepWorkspace(params.workspaceId, true) + } const installed = getStructuredAgentSessionHost() if (installed) { - return installed.sleepWorkspace(params.workspaceId) + return installed.sleepWorkspace(params.workspaceId, params.markAsleep) } let store: Awaited> try { @@ -185,14 +188,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ } catch (error) { const appeared = getStructuredAgentSessionHost() if (appeared) { - return appeared.sleepWorkspace(params.workspaceId) + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) } console.warn('[agent-session] workspace sleep record inventory unavailable', error) return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } } const appeared = getStructuredAgentSessionHost() if (appeared) { - return appeared.sleepWorkspace(params.workspaceId) + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) } try { const records = store diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 7b135e0fce19..5736a839510e 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -651,9 +651,12 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) - it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { + it('keeps the host-fed chat row through Sleep and offers Sleep until its host marks the chat slept', async () => { const state = (): AppState => useAppStore.getState() const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) + mocks.store?.setState({ + unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, executionHostId: 'local' }] } + }) render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) @@ -675,7 +678,19 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) expect(statuses()[0].structuredHostOwned).toBeUndefined() expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) + // The host let go: only its Sleep mark stops offering Sleep, and the chat running again wakes it. + expect(sleepable()).toBe(true) + const slept = summary({ + status: 'idle', + updatedAt: 2, + hostExecutionOwned: undefined, + sleptAt: 5 + }) + act(() => feed().emit({ type: 'status', session: slept })) + expect(state().structuredChatSleptAtByPaneKey).toEqual({ [paneKey]: 5 }) expect(sleepable()).toBe(false) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 3, sleptAt: 5 }) })) + expect(Object.values(state().structuredChatSleptAtByPaneKey)).toEqual([null]) }) it('drops the status and the feed when the last structured tab closes', async () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index cd32e239fa02..9e0dd12b161b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -38,6 +38,7 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { useStructuredChatSleepProjection } from './structured-chat-sleep-projection' import { getStructuredAgentSessionTabs, type StructuredTab } from './structured-agent-session-tabs' // Re-exported so the bridge stays the one import site its consumers already know. @@ -317,6 +318,7 @@ function StructuredAgentSessionStatusProjection({ }): React.JSX.Element | null { const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) + useStructuredChatSleepProjection(tab, target) return target ? : null } diff --git a/src/renderer/src/components/native-chat/structured-chat-sleep-projection.ts b/src/renderer/src/components/native-chat/structured-chat-sleep-projection.ts new file mode 100644 index 000000000000..bdc029bd0439 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-chat-sleep-projection.ts @@ -0,0 +1,32 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { useAppStore } from '@/store' + +export function useStructuredChatSleepProjection( + tab: { id: string; entityId: string }, + target: RuntimeClientTarget | null +): void { + const feed = useMemo( + () => (target ? getStructuredAgentSessionStatusFeed(target) : null), + [target] + ) + const sleptAt = useSyncExternalStore( + feed?.subscribe ?? (() => () => {}), + // An unidentified host keeps the workspace visible without guessing where its chat runs. + () => (feed ? feed.getSessionSleepState(tab.entityId) : null), + () => undefined + ) + const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) + useEffect(() => { + const store = useAppStore.getState() + if (sleptAt !== undefined) { + store.setStructuredChatSleptAt(paneKey, sleptAt) + } + }, [paneKey, sleptAt, feed]) + useEffect( + () => () => useAppStore.getState().setStructuredChatSleptAt(paneKey, undefined), + [paneKey, feed] + ) +} diff --git a/src/renderer/src/components/native-chat/structured-chat-sleep-startup.test.tsx b/src/renderer/src/components/native-chat/structured-chat-sleep-startup.test.tsx new file mode 100644 index 000000000000..abacca52bf06 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-chat-sleep-startup.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' +import { getWorktreeIdsWithStructuredChat } from '../sidebar/visible-worktree-activity-inputs' +import { selectIsSleepingWorktree } from '../sidebar/use-worktree-sleep-state' +import { hasSleepableWorkspaceActivity } from '../sidebar/workspace-lineage-menu-actions' +import { + agentSessionTabFixture, + repoFixture, + worktreeFixture +} from './native-chat-workspace-test-fixtures' +const mocks = vi.hoisted(() => ({ subscribeStatus: vi.fn(), supportsCapability: vi.fn() })) +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) +import { useAppStore } from '@/store' +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +function emit(event: AgentSessionStatusEvent, index = 0): void { + act(() => mocks.subscribeStatus.mock.calls[index][1](event)) +} +afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() +}) +it('keeps an unidentified twin visible, then follows only its named host through disconnect and wake', async () => { + const workspaceId = 'repo::/repo/main' + const tab = agentSessionTabFixture('legacy', workspaceId) + const hosts = ['local', 'runtime:paired'] as const + mocks.supportsCapability.mockResolvedValue(true) + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: vi.fn() }) + useAppStore.setState({ + activeWorktreeId: 'another-workspace', + activeWorkspaceExecutionHostId: null, + repos: hosts.map((executionHostId) => repoFixture({ executionHostId })), + worktreesByRepo: { + repo: hosts.map((hostId) => worktreeFixture(workspaceId, '/repo/main', { hostId })) + }, + unifiedTabsByWorktree: { [workspaceId]: [tab] }, + structuredChatSleptAtByPaneKey: {}, + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + browserTabsByWorktree: {} + }) + const state = useAppStore.getState + const awake = () => + getWorktreeIdsWithStructuredChat( + state().unifiedTabsByWorktree, + state().structuredChatSleptAtByPaneKey + ).has(workspaceId) + const sleeping = () => selectIsSleepingWorktree(state(), workspaceId) + render() + expect(awake()).toBe(true) + expect(sleeping()).toBe(false) + expect(mocks.subscribeStatus).not.toHaveBeenCalled() + act(() => + useAppStore.setState({ activeWorktreeId: workspaceId, activeWorkspaceExecutionHostId: 'local' }) + ) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + expect(awake()).toBe(false) + expect(sleeping()).toBe(false) + expect(hasSleepableWorkspaceActivity(workspaceId, state())).toBe(false) + for (const livePanel of [ + { browserTabsByWorktree: { [workspaceId]: [{ id: 'browser' }] } }, + { + tabsByWorktree: { [workspaceId]: [{ id: 'terminal' }] }, + ptyIdsByTabId: { terminal: ['pty'] } + } + ]) { + expect(hasSleepableWorkspaceActivity(workspaceId, { ...state(), ...livePanel })).toBe(true) + } + emit({ type: 'end' }) + expect(awake()).toBe(false) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2)) + const session = { + sessionId: tab.entityId, + workspaceId, + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + sleptAt: 5 + } as const + emit({ type: 'snapshot', sessions: [session] }, 1) + expect(sleeping()).toBe(true) + emit({ type: 'end' }, 1) + cleanup() + render() + expect(sleeping()).toBe(true) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledTimes(3)) + emit({ type: 'status', session: { ...session, sleptAt: undefined } }, 2) + expect(awake()).toBe(true) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index f8507970523d..717e4b652577 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -7,6 +7,8 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { Tab } from '../../../../shared/tab-types' +import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-workspace-sleep' +import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), supports: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -279,7 +281,10 @@ describe('native chat names in Vault rows', () => { await vi.advanceTimersByTimeAsync(60_000) }) expect(screen.getByText('Saved by older host')).toBeTruthy() - expect(mocks.supports).toHaveBeenCalledTimes(1) + expect(mocks.supports.mock.calls).toEqual([ + ['older', AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY], + ['older', AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY] + ]) expect(mocks.subscribe).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts index e2fb53e477aa..d317c74e664a 100644 --- a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts +++ b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts @@ -1,4 +1,7 @@ -import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' +import { + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY, + type AgentSessionWorkspaceSleepResult +} from '../../../../shared/agent-session-workspace-sleep' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId, @@ -8,6 +11,7 @@ import { authorityKey, SELF_LOCAL_AUTHORITY } from '../../../../shared/host-auth import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { resolveOwner } from '@/lib/resolve-owner' import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' +import { readCachedRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { workspacePublishingHosts } from '@/store/slices/worktrees/teardown/host-twin-worktree-purge' import type { AppState } from '@/store/types' @@ -47,17 +51,32 @@ function chatHostTargets( return targets } -/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. */ +/** Whether the host keeps Sleep's mark on its chats; the local host is this build. Read from the + * last probe, because the stop must be sent in the same step that admitted it; an unprobed host + * marks nothing and its workspace stays awake. */ +function hostMarksSleep(target: RuntimeClientTarget): boolean { + return ( + target.kind === 'local' || + readCachedRuntimeEnvironmentCapability( + target.environmentId, + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY + ) === true + ) +} + +/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. + * `markAsleep` asks each host that can to record the chats it stopped as put to sleep. */ export async function stopWorkspaceChats( state: AppState, - workspaceId: string + workspaceId: string, + markAsleep = false ): Promise { const settled = await Promise.all( [...chatHostTargets(state, workspaceId)].map(([executionHostId, target]) => callStructuredAgentSession( target, 'agentSession.sleepWorkspace', - { workspaceId } + markAsleep && hostMarksSleep(target) ? { workspaceId, markAsleep } : { workspaceId } ).then( (result) => ({ executionHostId, result, error: undefined }), (error: unknown) => ({ executionHostId, result: null, error }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 42567ecfb496..f7ce87aad87e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -33,8 +33,10 @@ const mocks = vi.hoisted(() => { const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() const callChat = vi.fn() + const hostMarksSleep = vi.fn() return { callChat, + hostMarksSleep, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -59,6 +61,10 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.callChat })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + readCachedRuntimeEnvironmentCapability: mocks.hostMarksSleep +})) import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { @@ -98,6 +104,7 @@ describe('runSleepWorktree', () => { mocks.state.ptyIdsByTabId = {} mocks.state.worktreesByRepo = {} mocks.callChat.mockReset().mockResolvedValue({ stoppedSessionIds: [] }) + mocks.hostMarksSleep.mockReset().mockReturnValue(true) }) it('tears down browsers before terminals on the sleep path', async () => { @@ -340,6 +347,10 @@ describe('runSleepWorktree', () => { expect(outcome.failedWorktreeIds).toEqual(['wt-1']) expect(outcome.errors).toHaveLength(1) expect(mocks.toastError).not.toHaveBeenCalled() + // A project move pauses through this core; only the user's Sleep asks hosts to mark chats. + expect(mocks.callChat.mock.calls.map(([, , params]) => params)).toEqual([ + { workspaceId: 'wt-2' } + ]) }) it('sleeps multiple worktrees and clears active only once when included', async () => { @@ -378,9 +389,20 @@ describe('runSleepWorktree', () => { mocks.callChat.mockImplementation(async (target: { kind: string }) => ({ stoppedSessionIds: [`chat-${target.kind}`] })) + mocks.hostMarksSleep.mockReturnValue(null) const results = await runSleepWorktrees([twinId]) + // Only a host known to keep the mark is asked to; an older one would refuse the whole stop. + expect(mocks.callChat.mock.calls.map(([target, , params]) => [target.kind, params])).toEqual([ + ['local', { workspaceId: twinId, markAsleep: true }], + ['environment', { workspaceId: twinId }] + ]) + expect(mocks.hostMarksSleep).toHaveBeenCalledWith( + 'env-a', + 'agent-session.workspace-sleep-mark.v1' + ) + expect(results).toEqual([ expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['chat-local'] }), expect.objectContaining({ diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 13c5fa05771f..ec140c5d37f8 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -80,7 +80,9 @@ function describeChatStopFailure(): string { export async function runSleepWorktrees( worktreeIds: readonly string[] ): Promise { - const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds) + const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds, { + markChatsAsleep: true + }) if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( @@ -110,7 +112,9 @@ export type WorktreeSleepOutcome = { /** The sleep without the toast, for callers that report failures themselves. */ export async function sleepWorktreesReportingFailures( - requestedWorktreeIds: readonly string[] + requestedWorktreeIds: readonly string[], + // Why: only the user's Sleep hides its chats; a project move pauses through this core too. + options: { markChatsAsleep?: boolean } = {} ): Promise { // Why unique: both copies of a twin share one id, and sleeping it sleeps the whole workspace. const worktreeIds = [...new Set(requestedWorktreeIds)] @@ -149,7 +153,11 @@ export async function sleepWorktreesReportingFailures( await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true, onAdmitted: () => { - chats = stopWorkspaceChats(useAppStore.getState(), worktreeId).catch((error: unknown) => { + chats = stopWorkspaceChats( + useAppStore.getState(), + worktreeId, + options.markChatsAsleep + ).catch((error: unknown) => { console.error('[sleep-worktree] native chat stop failed', { worktreeId, error }) return { receipts: [], failed: true } }) diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx index 3175a35f43c0..71ef80e8b1d2 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { cleanup, renderHook } from '@testing-library/react' import { useAppStore } from '@/store' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { Tab } from '../../../../shared/tab-types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' @@ -59,6 +60,9 @@ describe('useVisibleWorkspaceKanbanWorktreeIds', () => { useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] }, unifiedTabsByWorktree: { [worktree.id]: [structuredTab] }, + structuredChatSleptAtByPaneKey: { + [structuredAgentSessionPaneKey(structuredTab.id, structuredTab.entityId)]: null + }, showSleepingWorkspaces: false }) diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 262aa9823eb7..f9f1acae5c70 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -53,7 +53,11 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ !showSleepingWorkspaces ? s.browserTabsByWorktree : null ) const worktreeIdsWithStructuredChat = useAppStore((s) => - getStructuredChatWorktreeIds(showSleepingWorkspaces, s.unifiedTabsByWorktree) + getStructuredChatWorktreeIds( + showSleepingWorkspaces, + s.unifiedTabsByWorktree, + s.structuredChatSleptAtByPaneKey + ) ) const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) // Why: skip the clock entirely when the epoch is the opt-out sentinel, so a diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index d7f4849ba314..b30b3a3dbc84 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -19,6 +19,7 @@ import { EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, + EMPTY_STRUCTURED_CHAT_SLEPT_AT, EMPTY_PTY_IDS_BY_TAB_ID, EMPTY_TABS_BY_WORKTREE, EMPTY_UNIFIED_TABS_BY_WORKTREE, @@ -129,6 +130,9 @@ export function useWorktreeContextMenuModel({ const agentStatusByPaneKey = useAppStore((s) => selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY) ) + const structuredChatSleptAtByPaneKey = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.structuredChatSleptAtByPaneKey, EMPTY_STRUCTURED_CHAT_SLEPT_AT) + ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) ) @@ -139,38 +143,37 @@ export function useWorktreeContextMenuModel({ const workspaceScope = parseWorkspaceKey(worktree.id) const folderWorkspaceId = workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null - const sleepableWorktrees = useMemo( - () => - activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item.id, { - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - unifiedTabsByWorktree, - agentStatusByPaneKey - }) - ), + const sleepActivity = useMemo( + () => ({ + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey, + structuredChatSleptAtByPaneKey + }), [ - activeContextWorktrees, agentStatusByPaneKey, browserTabsByWorktree, + structuredChatSleptAtByPaneKey, ptyIdsByTabId, tabsByWorktree, unifiedTabsByWorktree ] ) + const sleepableWorktrees = useMemo( + () => + activeContextWorktrees.filter((item) => + hasSleepableWorkspaceActivity(item.id, sleepActivity) + ), + [activeContextWorktrees, sleepActivity] + ) const lineageMenuActions = useWorkspaceLineageMenuActions({ enabled: !isMultiContext, parent: worktree, worktrees: allWorktrees, lineageById: worktreeLineageById, - activity: { - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - unifiedTabsByWorktree, - agentStatusByPaneKey - } + activity: sleepActivity }) const lineageDescendantCount = lineageMenuActions.descendants.length const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx index e62b0759c307..64a555cc8896 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx @@ -16,6 +16,7 @@ type MockState = { tabsByWorktree: Record browserTabsByWorktree: Record unifiedTabsByWorktree: Record + structuredChatSleptAtByPaneKey: Record ptyIdsByTabId: Record agentStatusEpoch: number agentStatusByPaneKey: Record @@ -96,6 +97,7 @@ describe('useIsSleepingWorktree', () => { tabsByWorktree: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, + structuredChatSleptAtByPaneKey: {}, ptyIdsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts index 328e10c03cd1..08129df4f3b5 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts @@ -4,6 +4,10 @@ import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' +import { + hasUnknownStructuredChat, + type StructuredChatSleptAtByPaneKey +} from '@/store/slices/structured-chat-slept-at' type TabLike = { id: string } @@ -16,6 +20,7 @@ type SleepStateInput = { ptyIdsByTabId?: Record | null browserTabsByWorktree?: Record | null unifiedTabsByWorktree?: Record | null + structuredChatSleptAtByPaneKey?: StructuredChatSleptAtByPaneKey } type LiveAgentGeneration = { @@ -68,13 +73,24 @@ function selectWorktreeIdsWithLiveAgent(state: SleepStateInput): ReadonlySet export type BrowserActivityTab = Pick @@ -37,40 +39,55 @@ export function getVisibleWorktreeBrowserActivityTabs( } export const EMPTY_STRUCTURED_CHAT_WORKTREE_IDS: ReadonlySet = new Set() -const structuredChatWorktreeIds = new WeakMap, ReadonlySet>() +const EMPTY_SLEPT_CHATS: StructuredChatSleptAtByPaneKey = {} +const structuredChatWorktreeIds = new WeakMap< + Record, + WeakMap> +>() /** - * Worktree ids holding a structured chat tab. + * Workspace ids holding a structured chat that Sleep has not put to rest. * - * Why existence rather than a live provider child: that child is held only while the chat's pane is - * visible and is evicted after it sits idle unheld, so keying on it would flip a workspace to sleeping - * after any worktree switch and report a process recycle the user never sees. The chat itself — its - * transcript, and its ability to take the next send — outlives the child. + * Why the host's Sleep mark rather than the provider child: the child starts only on a send and the + * host's idle sweep stops it, so keying on it would put every quiet chat workspace to sleep after a + * restart or a pause. Only the user's Sleep marks a chat, and the next admitted send clears it. */ export function getWorktreeIdsWithStructuredChat( - unifiedTabsByWorktree: Record | null | undefined + unifiedTabsByWorktree: Record | null | undefined, + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey = EMPTY_SLEPT_CHATS ): ReadonlySet { if (!unifiedTabsByWorktree) { return EMPTY_STRUCTURED_CHAT_WORKTREE_IDS } // Keyed on the snapshot, like the tab projection it reads: zustand re-runs every mounted card's // selector on each store write, and this is a whole-store scan. - const cached = structuredChatWorktreeIds.get(unifiedTabsByWorktree) + const cached = structuredChatWorktreeIds + .get(unifiedTabsByWorktree) + ?.get(structuredChatSleptAtByPaneKey) if (cached) { return cached } const worktreeIds = new Set( - getStructuredAgentSessionTabs(unifiedTabsByWorktree).map((tab) => tab.worktreeId) + getStructuredAgentSessionTabs(unifiedTabsByWorktree) + .filter( + (tab) => + structuredChatSleptAtByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] === + null + ) + .map((tab) => tab.worktreeId) ) - structuredChatWorktreeIds.set(unifiedTabsByWorktree, worktreeIds) + const bySleptAt = structuredChatWorktreeIds.get(unifiedTabsByWorktree) ?? new WeakMap() + bySleptAt.set(structuredChatSleptAtByPaneKey, worktreeIds) + structuredChatWorktreeIds.set(unifiedTabsByWorktree, bySleptAt) return worktreeIds } export function getStructuredChatWorktreeIds( showSleepingWorkspaces: boolean, - unifiedTabsByWorktree: Record | null | undefined + unifiedTabsByWorktree: Record | null | undefined, + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey = EMPTY_SLEPT_CHATS ): ReadonlySet { return showSleepingWorkspaces ? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS - : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree) + : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree, structuredChatSleptAtByPaneKey) } diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts index 61eb0313bce1..75651b1b6f90 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -31,7 +31,10 @@ export function buildVisibleWorktreeOptionsFromState( state.tabsByWorktree, Date.now() ), - worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree), + worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat( + state.unifiedTabsByWorktree, + state.structuredChatSleptAtByPaneKey + ), hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index 319880a34b82..90307d0fe7b6 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -70,7 +70,8 @@ describe('workspace lineage menu actions', () => { [grandchild.id]: [{ id: 'grandchild-browser' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + structuredChatSleptAtByPaneKey: {} } }) @@ -97,7 +98,8 @@ describe('workspace lineage menu actions', () => { ptyIdsByTabId: { 'child-tab': ['child-pty'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + structuredChatSleptAtByPaneKey: {} } }) @@ -118,7 +120,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': [] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + structuredChatSleptAtByPaneKey: {} }) ).toBe(false) }) @@ -130,7 +133,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': ['pty-1'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + structuredChatSleptAtByPaneKey: {} }) ).toBe(true) expect( @@ -139,7 +143,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + structuredChatSleptAtByPaneKey: {} }) ).toBe(true) }) @@ -164,9 +169,12 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [chat] }, - agentStatusByPaneKey + agentStatusByPaneKey, + structuredChatSleptAtByPaneKey: { + [structuredAgentSessionPaneKey(chat.id, chat.entityId)]: null + } }) expect(activity(hostHeld(chat))).toBe(true) - expect(activity({})).toBe(false) + expect(activity({})).toBe(true) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 975c3b0338ab..94fb5fc91b3b 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -5,6 +5,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { StructuredChatSleptAtByPaneKey } from '@/store/slices/structured-chat-slept-at' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { @@ -13,6 +14,7 @@ type WorkspaceActivityMaps = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record agentStatusByPaneKey: Record + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey } export type WorkspaceLineageMenuActions = { @@ -29,12 +31,15 @@ export function hasSleepableWorkspaceActivity( return ( tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || - // A chat counts only while its host holds its process; a stopped one has nothing to sleep. + // An idle-ended chat still needs Sleep to hide it, even when its legacy tab has no host stamp. (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => isStructuredTab(tab) && - activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] - ?.structuredHostOwned === true + (activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + ?.structuredHostOwned === true || + activity.structuredChatSleptAtByPaneKey[ + structuredAgentSessionPaneKey(tab.id, tab.entityId) + ] === null) ) ) } @@ -71,7 +76,8 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey + agentStatusByPaneKey, + structuredChatSleptAtByPaneKey } = activity return useMemo( () => @@ -85,7 +91,8 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey + agentStatusByPaneKey, + structuredChatSleptAtByPaneKey } }) : EMPTY_LINEAGE_MENU_ACTIONS, @@ -93,6 +100,7 @@ export function useWorkspaceLineageMenuActions( agentStatusByPaneKey, browserTabsByWorktree, enabled, + structuredChatSleptAtByPaneKey, lineageById, parent, ptyIdsByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index e3a24afd41e1..1159112ca344 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -29,6 +29,7 @@ export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_STRUCTURED_CHAT_SLEPT_AT: AppState['structuredChatSleptAtByPaneKey'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index 95cf9b7b963d..ef0b895c95ae 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -114,7 +114,10 @@ const REVEAL_WORKSPACE: Record - getStructuredChatWorktreeIds(showSleepingWorkspaces, s.unifiedTabsByWorktree) + getStructuredChatWorktreeIds( + showSleepingWorkspaces, + s.unifiedTabsByWorktree, + s.structuredChatSleptAtByPaneKey + ) ) // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts index 4ba50df69598..8ee723cab674 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts @@ -188,120 +188,121 @@ export function registerPtyConnectionSplitSpawnTests(): void { 'disposed', 'hide-wait', 'disposed-hide-wait' - ])( - 'preserves first-fit startup and bounded fallback: %s', - async (scenario) => { - const visibility = scenario === 'hidden' ? 'hidden' : 'visible' - const narrow = scenario.startsWith('narrow-') - const pendingCwd = scenario.includes('cwd') ? createDeferred() : null - const hideWait = scenario.endsWith('hide-wait') - let measurable = !pendingCwd && scenario !== 'disposed' && !hideWait - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const doc = Object.assign(new EventTarget(), { - visibilityState: visibility, - addEventListener: vi.fn(EventTarget.prototype.addEventListener), - removeEventListener: vi.fn(EventTarget.prototype.removeEventListener) - }) - vi.stubGlobal('document', doc) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + ])('preserves first-fit startup and bounded fallback: %s', async (scenario) => { + const visibility = scenario === 'hidden' ? 'hidden' : 'visible' + const narrow = scenario.startsWith('narrow-') + const pendingCwd = scenario.includes('cwd') ? createDeferred() : null + const hideWait = scenario.endsWith('hide-wait') + let measurable = !pendingCwd && scenario !== 'disposed' && !hideWait + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const doc = Object.assign(new EventTarget(), { + visibilityState: visibility, + addEventListener: vi.fn(EventTarget.prototype.addEventListener), + removeEventListener: vi.fn(EventTarget.prototype.removeEventListener) + }) + vi.stubGlobal('document', doc) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { connectPanePty } = await import('./pty-connection') - const { applyOrDeferPaneMetricOptions } = - await import('@/lib/pane-manager/pane-metric-options-deferral') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - Object.assign(pane.terminal, { cols: 80, rows: 24 }) - const terminalOptions = Object.assign(pane.terminal.options, { fontSize: 12 }) - pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const dimensions = () => { - if (!measurable) { - return { cols: 0, rows: 0 } - } - return narrow || terminalOptions.fontSize === 24 - ? { cols: 5, rows: 20 } - : { cols: 120, rows: 40 } + const { connectPanePty } = await import('./pty-connection') + const { applyOrDeferPaneMetricOptions } = + await import('@/lib/pane-manager/pane-metric-options-deferral') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + Object.assign(pane.terminal, { cols: 80, rows: 24 }) + const terminalOptions = Object.assign(pane.terminal.options, { fontSize: 12 }) + pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) + const dimensions = () => { + if (!measurable) { + return { cols: 0, rows: 0 } } - pane.fitAddon.proposeDimensions = vi.fn(dimensions) - pane.fitAddon.fit = vi.fn(() => { - Object.assign(pane.terminal, dimensions()) - }) - const startup = scenario === 'narrow-plain' ? null : { command: 'claude' } - const deps = createDeps({ - startup, - ...(pendingCwd ? { cwdPromise: pendingCwd.promise } : {}) - }) - if (scenario === 'narrow-remote') { - mockStoreState.repos[0].executionHostId = 'runtime:env-1' - mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + return narrow || terminalOptions.fontSize === 24 + ? { cols: 5, rows: 20 } + : { cols: 120, rows: 40 } + } + pane.fitAddon.proposeDimensions = vi.fn(dimensions) + pane.fitAddon.fit = vi.fn(() => { + Object.assign(pane.terminal, dimensions()) + }) + const startup = scenario === 'narrow-plain' ? null : { command: 'claude' } + const deps = createDeps({ + startup, + ...(pendingCwd ? { cwdPromise: pendingCwd.promise } : {}) + }) + if (scenario === 'narrow-remote') { + mockStoreState.repos[0].executionHostId = 'runtime:env-1' + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. + const args = [pane, createManager(1), deps] as unknown as Parameters + if (scenario === 'metrics') { + applyOrDeferPaneMetricOptions(args[0], { fontSize: 24 }, false) + } + const binding = connectPanePty(...args) + + if (visibility === 'visible' && scenario !== 'starved') { + frameCallbacks.shift()?.(0) + const hideListener = doc.addEventListener.mock.calls.at(-1)?.[1] + if ( + pendingCwd || + hideWait || + ['metrics', 'disposed', 'narrow-command'].includes(scenario) + ) { + expect(transport.connect).not.toHaveBeenCalled() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. - const args = [pane, createManager(1), deps] as unknown as Parameters - if (scenario === 'metrics') { - applyOrDeferPaneMetricOptions(args[0], { fontSize: 24 }, false) + if (scenario.startsWith('disposed')) { + binding.dispose() } - const binding = connectPanePty(...args) - - if (visibility === 'visible' && scenario !== 'starved') { - frameCallbacks.shift()?.(0) - const hideListener = doc.addEventListener.mock.calls.at(-1)?.[1] - if (pendingCwd || hideWait || ['metrics', 'disposed', 'narrow-command'].includes(scenario)) { - expect(transport.connect).not.toHaveBeenCalled() - } - if (scenario.startsWith('disposed')) { - binding.dispose() - } - if (hideWait) { - doc.visibilityState = 'hidden' - doc.dispatchEvent(new Event('visibilitychange')) - expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - expect(doc.removeEventListener).toHaveBeenCalledWith('visibilitychange', hideListener) - if (typeof hideListener === 'function') { - hideListener(new Event('visibilitychange')) - } - } - if (scenario === 'late-cwd') { - vi.advanceTimersByTime(250) - expect(transport.connect).not.toHaveBeenCalled() - } - pendingCwd?.resolve('/workspace') - await flushAsyncTicks() - if (scenario === 'cwd') { - expect(transport.connect).not.toHaveBeenCalled() - } - measurable = true - terminalOptions.fontSize = 12 - if (scenario === 'narrow-command') { - for (let retry = 0; retry < 11; retry += 1) { - frameCallbacks.shift()?.(0) - } - expect(transport.connect).not.toHaveBeenCalled() - } - frameCallbacks.shift()?.(0) + if (hideWait) { + doc.visibilityState = 'hidden' + doc.dispatchEvent(new Event('visibilitychange')) expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - } else { + expect(doc.removeEventListener).toHaveBeenCalledWith('visibilitychange', hideListener) + if (typeof hideListener === 'function') { + hideListener(new Event('visibilitychange')) + } + } + if (scenario === 'late-cwd') { vi.advanceTimersByTime(250) + expect(transport.connect).not.toHaveBeenCalled() } - - vi.advanceTimersByTime(250) - expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - if (scenario.startsWith('disposed')) { - return + pendingCwd?.resolve('/workspace') + await flushAsyncTicks() + if (scenario === 'cwd') { + expect(transport.connect).not.toHaveBeenCalled() } - expect(createdTransportOptions[0]?.command).toBe(startup?.command) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining( - narrow || hideWait || scenario === 'late-cwd' - ? { cols: 80, rows: 24 } - : { cols: 120, rows: 40 } - ) - ) + measurable = true + terminalOptions.fontSize = 12 + if (scenario === 'narrow-command') { + for (let retry = 0; retry < 11; retry += 1) { + frameCallbacks.shift()?.(0) + } + expect(transport.connect).not.toHaveBeenCalled() + } + frameCallbacks.shift()?.(0) + expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) + } else { + vi.advanceTimersByTime(250) } - ) + + vi.advanceTimersByTime(250) + expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) + if (scenario.startsWith('disposed')) { + return + } + expect(createdTransportOptions[0]?.command).toBe(startup?.command) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining( + narrow || hideWait || scenario === 'late-cwd' + ? { cols: 80, rows: 24 } + : { cols: 120, rows: 40 } + ) + ) + }) it('spawns the delayed primary pane of an issue-command split when its sibling owns the tab PTY fallback', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index e9bfd270e245..c6f2ca3ee85b 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -69,6 +69,9 @@ export function useWorktreeJumpPaletteStoreState({ const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((state) => state.browserPagesByWorkspace) const unifiedTabsByWorktree = useAppStore((state) => state.unifiedTabsByWorktree) + const structuredChatSleptAtByPaneKey = useAppStore( + (state) => state.structuredChatSleptAtByPaneKey + ) const paletteIndexStatus = useMemo( () => selectPaletteIndexStatusSnapshot(useAppStore.getState(), paletteStatusInputsActive), // oxlint-disable-next-line react-hooks/exhaustive-deps -- these deps are the snapshot refresh policy. @@ -161,6 +164,7 @@ export function useWorktreeJumpPaletteStoreState({ browserTabsByWorktree, browserPagesByWorkspace, unifiedTabsByWorktree, + structuredChatSleptAtByPaneKey, agentStatusByPaneKey, runtimePaneTitlesByTabId, unreadTerminalTabs, diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index cd6bd019ce53..9d138e680d41 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -40,6 +40,7 @@ export function useWorktreeJumpPaletteWorktrees({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + structuredChatSleptAtByPaneKey, activeWorktreeId, activeWorkspaceExecutionHostId, runtimeEnvironments, @@ -81,7 +82,8 @@ export function useWorktreeJumpPaletteWorktrees({ ) const worktreeIdsWithStructuredChat = getStructuredChatWorktreeIds( showSleepingWorkspaces, - unifiedTabsByWorktree + unifiedTabsByWorktree, + structuredChatSleptAtByPaneKey ) const emptyQueryVisibleWorktrees = useMemo(() => { // Why no project or host scope: the palette ignores the sidebar's scope; filterPredicate scopes it. diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts index 052e18047b94..e1bd2767f1f2 100644 --- a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts @@ -23,10 +23,14 @@ export function readWindowWorkspaceActivityFacts( | 'browserTabsByWorktree' | 'unifiedTabsByWorktree' | 'agentStatusByPaneKey' - >, + > & + Partial>, now: number ): Map { - const structuredChatIds = getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree) + const structuredChatIds = getWorktreeIdsWithStructuredChat( + state.unifiedTabsByWorktree, + state.structuredChatSleptAtByPaneKey + ) const agentById = getLiveAgentStatusByWorktreeId( state.agentStatusByPaneKey, state.tabsByWorktree, diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index de1617cdb1b6..04c408c699a3 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -66,7 +66,8 @@ describe('structured agent session status feed', () => { it('never subscribes, and never retries, against a host without the status feed', async () => { mocks.supportsCapability.mockResolvedValue(false) - getStructuredAgentSessionStatusFeed(REMOTE).activate() + const feed = getStructuredAgentSessionStatusFeed(REMOTE) + feed.activate() await vi.advanceTimersByTimeAsync(0) expect(mocks.supportsCapability).toHaveBeenCalledWith( @@ -75,10 +76,11 @@ describe('structured agent session status feed', () => { ) expect(mocks.subscribeStatus).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) + expect(feed.getSessionSleepState('session-1')).toBeNull() await vi.advanceTimersByTimeAsync(60_000) expect(mocks.subscribeStatus).not.toHaveBeenCalled() - expect(mocks.supportsCapability).toHaveBeenCalledOnce() + expect(mocks.supportsCapability).toHaveBeenCalledTimes(2) }) it('subscribes once the remote host advertises the status feed', async () => { @@ -91,14 +93,20 @@ describe('structured agent session status feed', () => { it('reconnects when the capability probe fails, which is not an answer', async () => { mocks.supportsCapability.mockRejectedValue(new Error('relay unreachable')) - getStructuredAgentSessionStatusFeed(REMOTE).activate() + const feed = getStructuredAgentSessionStatusFeed(REMOTE) + feed.activate() await vi.advanceTimersByTimeAsync(0) - expect(mocks.supportsCapability).toHaveBeenCalledOnce() + expect(mocks.supportsCapability).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(300) - expect(mocks.supportsCapability).toHaveBeenCalledTimes(2) + expect(mocks.supportsCapability).toHaveBeenCalledTimes(4) expect(mocks.subscribeStatus).not.toHaveBeenCalled() + expect(feed.getSessionSleepState('session-1')).toBeUndefined() + mocks.supportsCapability.mockResolvedValue(true) + await vi.advanceTimersByTimeAsync(500) + hostEmit()({ type: 'snapshot', sessions: [{ ...summary('session-1'), sleptAt: 5 }] }) + expect(feed.getSessionSleepState('session-1')).toBe(5) }) it('probes nothing for a local host, which is this build', async () => { @@ -109,6 +117,26 @@ describe('structured agent session status feed', () => { expect(mocks.subscribeStatus).toHaveBeenCalledOnce() }) + it('keeps Sleep unknown through naming metadata until the full status answer', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const naming: AgentSessionStatusEvent = { + type: 'snapshot', + sessions: [], + namingActiveSessionIds: [] + } + hostEmit()(naming) + expect(feed.getNamingActive('session-1')).toBe(false) + expect(feed.getSessionSleepState('session-1')).toBeUndefined() + hostEmit()({ type: 'snapshot', sessions: [{ ...summary('session-1'), sleptAt: 5 }] }) + hostEmit()(naming) + expect(feed.getSessionSleepState('session-1')).toBe(5) + hostEmit()({ type: 'snapshot', sessions: [] }) + expect(feed.getSessionSleepState('session-2')).toBeNull() + expect(feed.getSessionSleepState('session-1')).toBe(5) + }) + it('merges a snapshot over the cached rows instead of retracting them', async () => { const feed = getStructuredAgentSessionStatusFeed(LOCAL) feed.activate() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index e26e924611a2..18f32f1db678 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -5,6 +5,7 @@ // keeps the cached summaries and reconnects; a fresh snapshot merges over them. // Which sessions are listed is the tab map's decision, so the feed never retracts a summary. +import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from '../../../shared/agent-session-workspace-sleep' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { foldAgentSessionStatusEvent, @@ -21,6 +22,7 @@ export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void getSnapshot: () => StructuredAgentSessionStatusSnapshot + getSessionSleepState: (sessionId: string) => number | null | undefined getNamingActive: (sessionId: string) => boolean | null getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void @@ -41,8 +43,11 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { let snapshot: StructuredAgentSessionStatusSnapshot = new Map() let namingActiveSessionIds: Set | null = null const confirmedSessions = new Set() + // Disconnect revokes live execution, but each chat's last Sleep answer still holds. + const knownSleep = new Map() const listeners = new Set<() => void>() const activations = new Set() + let sleepResolved = false let generation = 0 let handle: { unsubscribe: () => void } | null = null let reconnectTimer: ReturnType | null = null @@ -59,15 +64,25 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } const applyEvent = (event: AgentSessionStatusEvent): void => { if (event.type === 'snapshot') { + // Naming metadata precedes the full status snapshot and says nothing about Sleep. + sleepResolved ||= event.namingActiveSessionIds === undefined reconnectAttempt = 0 if (event.namingActiveSessionIds !== undefined) { namingActiveSessionIds = new Set(event.namingActiveSessionIds) } for (const session of event.sessions) { confirmedSessions.add(session.sessionId) + knownSleep.set( + session.sessionId, + session.hostExecutionOwned ? null : (session.sleptAt ?? null) + ) } } else if (event.type === 'status') { confirmedSessions.add(event.session.sessionId) + knownSleep.set( + event.session.sessionId, + event.session.hostExecutionOwned ? null : (event.session.sleptAt ?? null) + ) } else { return } @@ -160,14 +175,24 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } const environmentId = target.environmentId - void runtimeEnvironmentSupportsCapability( - environmentId, - AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY - ) - .then((supported) => { + void Promise.all([ + runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + ), + runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY + ) + ]) + .then(([supported, marksSleep]) => { if (!active(candidate)) { return } + if (!marksSleep || !supported) { + sleepResolved = true + emit() + } // A host without the method is terminal, not a fault: retrying would relay-probe // forever. A failed probe is not an answer, so that path still reconnects. if (supported) { @@ -205,6 +230,10 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, getSnapshot: () => snapshot, + getSessionSleepState: (sessionId) => { + const known = knownSleep.get(sessionId) + return known !== undefined ? known : sleepResolved ? null : undefined + }, getNamingActive: (sessionId) => namingActiveSessionIds?.has(sessionId) ?? null, getSessionObservation: (sessionId) => confirmedSessions.has(sessionId) ? 'live' : 'unverifiable', diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 42253a6de1b5..837b3d2efcc7 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -49,6 +49,7 @@ import { createTaskCreationDraftsSlice } from './slices/task-creation-drafts' import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts' import { createStructuredSessionLaunchDirectorySlice } from './slices/structured-session-launch-directories' +import { createStructuredChatSleptAtSlice } from './slices/structured-chat-slept-at' import { createWorkspaceAttentionSlice } from './slices/workspace-attention/workspace-attention-slice' import { e2eConfig } from '@/lib/e2e-config' import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' @@ -138,6 +139,7 @@ export const useAppStore = create()( ...createRemoteServerUpdatesSlice(...a), ...createTerminalQuickCommandHostsSlice(...a), ...createStructuredSessionLaunchDirectorySlice(...a), + ...createStructuredChatSleptAtSlice(...a), ...createWorkspaceAttentionSlice(...a) } }) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 62ad3bb48cea..33b0add002df 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -50,6 +50,7 @@ import { createTaskCreationDraftsSlice } from './task-creation-drafts' import { createRemoteServerUpdatesSlice } from './remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './terminal-quick-command-hosts' import { createStructuredSessionLaunchDirectorySlice } from './structured-session-launch-directories' +import { createStructuredChatSleptAtSlice } from './structured-chat-slept-at' import { createWorkspaceAttentionSlice } from './workspace-attention/workspace-attention-slice' import '@/i18n/i18n' import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' @@ -111,6 +112,7 @@ export function createTestStore() { ...createRemoteServerUpdatesSlice(...a), ...createTerminalQuickCommandHostsSlice(...a), ...createStructuredSessionLaunchDirectorySlice(...a), + ...createStructuredChatSleptAtSlice(...a), ...createWorkspaceAttentionSlice(...a) })) } diff --git a/src/renderer/src/store/slices/structured-chat-slept-at.ts b/src/renderer/src/store/slices/structured-chat-slept-at.ts new file mode 100644 index 000000000000..4082ef7a21b0 --- /dev/null +++ b/src/renderer/src/store/slices/structured-chat-slept-at.ts @@ -0,0 +1,54 @@ +import type { StateCreator } from 'zustand' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '../types' + +/** Host Sleep answers and the unidentified-owner awake fallback, by chat pane key. + * Written only by the status bridge, dropped with its projection, never saved. */ +export type StructuredChatSleptAtByPaneKey = Readonly> + +export type StructuredChatSleptAtSlice = { + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey + setStructuredChatSleptAt: (paneKey: string, sleptAt: number | null | undefined) => void +} + +export function isStructuredChatSlept( + sleptAtByPaneKey: StructuredChatSleptAtByPaneKey, + tab: { id: string; entityId: string } +): boolean { + return typeof sleptAtByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] === 'number' +} + +/** Missing is unknown; null keeps the chat awake for display, without proving execution. */ +export function hasUnknownStructuredChat( + tabs: readonly Tab[] | undefined, + sleptAtByPaneKey: StructuredChatSleptAtByPaneKey = {} +): boolean { + return (tabs ?? []).some( + (tab) => + isStructuredTab(tab) && + sleptAtByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] === undefined + ) +} + +export const createStructuredChatSleptAtSlice: StateCreator< + AppState, + [], + [], + StructuredChatSleptAtSlice +> = (set) => ({ + structuredChatSleptAtByPaneKey: {}, + setStructuredChatSleptAt: (paneKey, sleptAt) => + set((state) => { + const current = state.structuredChatSleptAtByPaneKey + if (current[paneKey] === sleptAt) { + return state + } + const { [paneKey]: _previous, ...rest } = current + return { + structuredChatSleptAtByPaneKey: + sleptAt === undefined ? rest : { ...rest, [paneKey]: sleptAt } + } + }) +}) diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index de94bcb51679..3fb33c3e4965 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -47,6 +47,7 @@ import type { TaskCreationDraftsSlice } from './slices/task-creation-drafts' import type { RemoteServerUpdatesSlice } from './slices/remote-server-updates' import type { TerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts' import type { StructuredSessionLaunchDirectorySlice } from './slices/structured-session-launch-directories' +import type { StructuredChatSleptAtSlice } from './slices/structured-chat-slept-at' import type { WorkspaceAttentionSlice } from './slices/workspace-attention/workspace-attention-contract' export type AppState = RepoSlice & @@ -96,4 +97,5 @@ export type AppState = RepoSlice & RemoteServerUpdatesSlice & TerminalQuickCommandHostsSlice & StructuredSessionLaunchDirectorySlice & + StructuredChatSleptAtSlice & WorkspaceAttentionSlice diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts index ca6d242d24a6..31aff050e652 100644 --- a/src/shared/agent-session-record-stored-form.ts +++ b/src/shared/agent-session-record-stored-form.ts @@ -26,9 +26,17 @@ export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionR if (!providerHandleChain) { throw new Error('agent_session_provider_handle_invalid') } + const { sleptAt, ...rest } = record + const validSleep = typeof sleptAt === 'number' && Number.isFinite(sleptAt) && sleptAt > 0 return { - record: { ...record, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, - normalized: leaseCarriesLegacyHandoffValues(record.lease) + record: { + ...rest, + ...(validSleep ? { sleptAt } : {}), + providerHandleChain, + lease: normalizeLegacyHandoffLease(record.lease) + }, + normalized: + leaseCarriesLegacyHandoffValues(record.lease) || (sleptAt !== undefined && !validSleep) } } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 57f74a1a302a..0add8b94ba33 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -160,6 +160,8 @@ export type AgentSessionRecord = { conversationCommand?: AgentSessionConversationCommandRecord /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string + /** When the user's Sleep stopped this chat; the next admitted send clears it. */ + sleptAt?: number launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease createdAt: number diff --git a/src/shared/agent-session-runtime-capabilities.ts b/src/shared/agent-session-runtime-capabilities.ts index db2caf9990ef..38a74f3587c6 100644 --- a/src/shared/agent-session-runtime-capabilities.ts +++ b/src/shared/agent-session-runtime-capabilities.ts @@ -6,6 +6,7 @@ import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-sessio import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from './agent-session-permission-modes-capability' import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from './agent-session-workspace-sleep' import { STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES } from './structured-agent-session-surface-capabilities' export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = @@ -155,6 +156,7 @@ export const AGENT_SESSION_RUNTIME_CAPABILITIES = [ AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index b66db7967283..fc6bba7edb85 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -277,6 +277,9 @@ export type AgentSessionStatusSummary = { /** Why the host could not name this chat, kept in memory only while the chat still has the * name the failed run would have replaced. Absent from older hosts; older clients ignore it. */ namingError?: AgentSessionNamingError + /** The record's Sleep mark: the user's Sleep stopped this chat and nothing has been sent since. + * Absent from older hosts. */ + sleptAt?: number /** Host-path directory the session is held to regardless of its workspace's current directory * (a floating chat's pinned folder). Absent means resolve the workspace id; older hosts omit it. */ launchDirectory?: string diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts index ac275d90c069..0c4ceab6c302 100644 --- a/src/shared/agent-session-workspace-sleep.ts +++ b/src/shared/agent-session-workspace-sleep.ts @@ -1,3 +1,8 @@ +// Why: `agentSession.sleepWorkspace` params are strict, so only a host advertising this is sent +// `markAsleep`; an older one stops the chats and marks nothing, leaving their workspace awake. +export const AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY = + 'agent-session.workspace-sleep-mark.v1' as const + export type AgentSessionWorkspaceSleepResult = { stoppedSessionIds: string[] failures?: { diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index e66c68aebaf1..e2045b76a71b 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -270,17 +270,18 @@ export const RespondToQuestionParams = z } }) -export const SetOptionParams = z - .object({ - envelope: MutationEnvelope, - key: Identifier('Invalid option key'), - value: z.string().max(MAX_OPTION_LABEL) - }) - .strict() +export const SetOptionParams = z.strictObject({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) +}) export const OptionsParams = z.object({ sessionId: SessionId }).strict() export const ItemParams = OptionsParams.extend({ itemId: z.string().min(1) }).strict() -export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() +// Why optional: only a host advertising the workspace-sleep-mark capability is sent `markAsleep`. +export const SleepWorkspaceParams = z + .object({ workspaceId: z.string().min(1), markAsleep: z.literal(true).optional() }) + .strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) /** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */