Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f284818
fix(sidebar): a slept chat workspace reads asleep and hides
brennanb2025 Oct 18, 2026
86df5e4
fix(sidebar): a wake during Sleep's teardown keeps the workspace awake
brennanb2025 Oct 19, 2026
80921d4
Merge remote-tracking branch 'origin/main' into brennanb2025/slept-ch…
brennanb2025 Oct 19, 2026
00afaa6
test(sidebar): give the activation-race mock store the chat tab map S…
brennanb2025 Oct 19, 2026
274eff8
feat(native-chat): the host marks the chats an explicit Sleep stopped
brennanb2025 Oct 19, 2026
16e6105
refactor(sidebar): read a slept chat from its host's mark, not a wind…
brennanb2025 Oct 19, 2026
34530d0
fix(native-chat): preserve host-owned Sleep across fallback and legac…
brennanb2025 Oct 20, 2026
12bebe9
Merge origin/main and preserve Sleep receipts after provider start se…
brennanb2025 Oct 20, 2026
2fac7c2
fix(native-chat): clear Sleep when a new send is admitted
brennanb2025 Oct 20, 2026
67a8f65
chore: restore incoming documentation after merge hook formatting
brennanb2025 Oct 20, 2026
45b4e11
fix(native-chat): let admitted sends supersede pending Sleep receipts
brennanb2025 Oct 20, 2026
c323afe
fix(sidebar): wait for host Sleep state before showing restored chats
brennanb2025 Oct 20, 2026
c96fca5
fix(sidebar): retain host Sleep answers when chat projections remount
brennanb2025 Oct 20, 2026
aa0bd59
fix(native-chat): clear Sleep when queued drafts become sends
brennanb2025 Oct 20, 2026
13b1b70
test(sidebar): cover unknown and known chat Sleep state on the board
brennanb2025 Oct 20, 2026
fb9dd82
test(vault): expect both host status capability checks
brennanb2025 Oct 20, 2026
7d53c0a
test(native-chat): consolidate Sleep behavior coverage
brennanb2025 Oct 20, 2026
9ff4572
fix(native-chat): commit Sleep wake with send admission
brennanb2025 Oct 20, 2026
df2281e
test: pin visibility while chat host is unidentified
brennanb2025 Oct 20, 2026
3963fbd
Merge origin/main into slept chat workspace visibility
brennanb2025 Oct 20, 2026
75fdf27
test: consolidate workspace sleep regression flows
brennanb2025 Oct 20, 2026
85c308e
Merge main and preserve chat Sleep status with naming updates
brennanb2025 Oct 21, 2026
cbb493e
test: keep status feed regression within renderer boundary
brennanb2025 Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -122,7 +123,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
sessionId: string,
workspaceId: string,
executionHostId: ExecutionHostId
): Promise<{ fence: number; stopped: boolean } | null> => {
): Promise<WorkspaceSleepCloseResult | null> => {
const matches = (): boolean => {
const location = deps().store.getRecord(sessionId)?.location
return location?.workspaceId === workspaceId && location.executionHostId === executionHostId
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -78,7 +75,7 @@ export class StructuredAgentSessionHost {
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
private readonly reconcileLeases: ReturnType<typeof createRestartReconciler>
private readonly restore: ReturnType<typeof reveal.createStructuredAgentSessionHostRestore>
private readonly lifetime: StructuredAgentSessionConversationLifetime
private readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime
private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery
private readonly eventRecovery: StructuredAgentSessionEventRecovery
private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -359,7 +361,8 @@ export class StructuredAgentSessionQueuedMessageDrain {
settledByOp: null,
hostInstance: structuredAgentSessionHostInstance(),
yieldsToPause: true
}
},
this.deps.submissionReceipt?.(sessionId)
)
} catch (error) {
if (error instanceof QueuedMessageNotConsumableError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand All @@ -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<StructuredAgentSessionStatusFeedDeps, 'getRecord' | 'namingFailure'>,
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
}
Loading
Loading