refactor(commands): move the command service to Remote
`CommandService.list` and `execute` carry the wire contract directly through `@Remote`, and the Client assembly mounts the generated commands contribution. The legacy API Proxy route, its schemas, the map rows, the generated client methods and the fixture's command domain are removed, so the catalog and the admission call have one owner again. `Session.command()` keeps a result-shaped public face for parity with the prompt, cancel and attachment neighbours it sits beside, and reads the generated namespace through one `SessionRemotes` parameter. The Session cluster declares that face against the owning business package rather than the generated contribution: the Host compiler aggregate builds this package, and it runs before any contribution is emitted. Migrated calls lose the `title-invalid` class of protocol-only error codes and report `internal`; no production caller branched on them.
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
@@ -28,7 +28,7 @@ describe('instances', () => {
|
||||
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const session = manager.get(S1)
|
||||
expect(manager.get(S1)).toBe(session) // resident: same instance forever
|
||||
@@ -37,7 +37,7 @@ describe('instances', () => {
|
||||
|
||||
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -50,7 +50,7 @@ describe('instances', () => {
|
||||
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
@@ -67,7 +67,7 @@ describe('instances', () => {
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -80,7 +80,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const first = manager.refreshList()
|
||||
const second = manager.refreshList()
|
||||
expect(manager.getListSnapshot().state).toBe('loading')
|
||||
@@ -96,7 +96,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const hydration = manager.refreshList()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'during-first' as never,
|
||||
@@ -116,7 +116,7 @@ describe('list lifecycle', () => {
|
||||
it('keeps the error in the list snapshot on failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
|
||||
// A failed pull does not step the arrival phase: still pending.
|
||||
@@ -125,7 +125,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('phase steps pending → ready on the first successful pull and never returns', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
expect(manager.getListSnapshot().phase).toBe('pending')
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().phase).toBe('ready')
|
||||
@@ -144,7 +144,7 @@ describe('list lifecycle', () => {
|
||||
it('merges create into the list immediately without waiting for a refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.create()
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
@@ -152,7 +152,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rpcId as never,
|
||||
@@ -179,7 +179,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
@@ -202,7 +202,7 @@ describe('list lifecycle', () => {
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const frame = (rpcId: string, payload: object) => {
|
||||
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
|
||||
@@ -230,7 +230,7 @@ describe('search', () => {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
@@ -246,7 +246,7 @@ describe('search', () => {
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
@@ -269,7 +269,7 @@ describe('search', () => {
|
||||
describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
@@ -303,7 +303,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
@@ -368,7 +368,7 @@ describe('subagent catalogs', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
@@ -418,7 +418,7 @@ describe('subagent catalogs', () => {
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -447,7 +447,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -488,7 +488,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -529,7 +529,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -547,7 +547,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
@@ -566,7 +566,7 @@ describe('subagent catalogs', () => {
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, root)
|
||||
const manager = new SessionManager(api, fakeRemote(), root)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
// A membership frame arrives while the pull is in flight; the debounced
|
||||
@@ -624,7 +624,7 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
@@ -671,7 +671,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(root)
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
|
||||
@@ -690,14 +690,14 @@ describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.reject(new Error('list wire down'))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
|
||||
})
|
||||
|
||||
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const session = manager.get(S1)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
await manager.refreshList()
|
||||
@@ -707,7 +707,7 @@ describe('remaining branches', () => {
|
||||
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
|
||||
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
|
||||
@@ -727,7 +727,7 @@ describe('remaining branches', () => {
|
||||
message: 'published but unattached',
|
||||
details: { sessionId: S1, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
|
||||
@@ -741,7 +741,7 @@ describe('remaining branches', () => {
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
@@ -754,7 +754,7 @@ describe('remaining branches', () => {
|
||||
it('reconciles a preallocated id after an ordinary transport failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([])
|
||||
@@ -775,7 +775,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
let notified = 0
|
||||
const unsubscribe = manager.subscribe(() => { notified++ })
|
||||
await manager.refreshList()
|
||||
@@ -790,7 +790,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
|
||||
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
|
||||
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
|
||||
@@ -805,7 +805,7 @@ describe('remaining branches', () => {
|
||||
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const before = manager.getListSnapshot()
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
|
||||
@@ -821,7 +821,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('carries parentSessionId from host/session-added into the lineage row', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'h2' as never,
|
||||
@@ -845,7 +845,7 @@ describe('connected generation', () => {
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const openedSession = manager.get(S1)
|
||||
await openedSession.open()
|
||||
manager.get(S2) // instantiated but never opened
|
||||
@@ -863,7 +863,7 @@ describe('connected generation', () => {
|
||||
const address = {
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
|
||||
}
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
const manager = new SessionManager(api, fakeRemote(), S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
@@ -876,7 +876,7 @@ describe('connected generation', () => {
|
||||
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -889,7 +889,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
@@ -922,7 +922,7 @@ describe('pending-interaction list status', () => {
|
||||
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
|
||||
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
|
||||
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q-plan' as never,
|
||||
@@ -939,7 +939,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
@@ -958,7 +958,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
@@ -973,7 +973,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
// Buffered pre-instantiation: an approval pair and a queued row.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -1000,7 +1000,7 @@ describe('completed reminder', () => {
|
||||
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
|
||||
|
||||
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1014,7 +1014,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S2)
|
||||
@@ -1029,7 +1029,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1044,7 +1044,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('session-removed drops the reminder and a re-add starts clean', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1060,7 +1060,7 @@ describe('completed reminder', () => {
|
||||
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -1072,7 +1072,7 @@ describe('completed reminder', () => {
|
||||
it('never arms for sessions already idle at first observation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -1085,7 +1085,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshList()
|
||||
// The session finishes while the first pull is still in flight; the pull
|
||||
// response recorded it as running at pull time.
|
||||
@@ -1099,7 +1099,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshList()
|
||||
// The unknown session starts and finishes while the first pull is in
|
||||
// flight; the pull-time baseline recorded it idle, so the running→idle
|
||||
@@ -1120,7 +1120,7 @@ describe('background-task mirror', () => {
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
|
||||
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
|
||||
const first = manager.getListSnapshot().tasksBySession
|
||||
@@ -1133,7 +1133,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('stores an emptied set as an absent key so absence and [] read alike', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
@@ -1141,7 +1141,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 's' as never,
|
||||
@@ -1151,7 +1151,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
@@ -1159,7 +1159,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
const seen = vi.fn()
|
||||
manager.subscribe(seen)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
|
||||
Reference in New Issue
Block a user