File size: 7,959 Bytes
09aec41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
 * Session Controller Client apply inside the assembled client: Remote events
 * arriving as emit frames on the `$events` stream, the control stream over
 * the real Connection, and Agent Context identity through the Typert registry.
 */
import type { Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { afterEach, describe, expect, vi, type MockInstance } from 'vitest'
import { ClientSessions } from '../src/client/sessions/service.ts'
import type { SessionListValue } from '../src/types.ts'

const SELF = '@deepseek-ai/dsh-api-session-controller'
const ROSTER = webApp.closure([SELF])
const it = createClientTest({ roster: ROSTER })
const EVENTS = '$events'
const CONTROL = 'session/control'
const BASELINE = { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }
/** The first client boot pays the cold module transform of the cone. */
const COLD_BOOT_TIMEOUT_MS = 60_000

const sid = (value: string): SessionId => value as SessionId

afterEach(() => {
  vi.restoreAllMocks()
})

async function bench(start: () => Promise<TestClient>) {
  const client = await start()
  return { client, sessions: client.ctx.sessions as ClientSessions }
}

/** Deliver one Remote event the way the Host forwards it: an emit frame on the `$events` stream, consumed by the client. */
async function emit(mock: RemoteMock, event: string, ...args: unknown[]): Promise<void> {
  mock.streams.push(EVENTS, { type: 'emit', event, args })
  await mock.streams.drained(EVENTS)
}

function baselines(accept: MockInstance): number {
  return accept.mock.calls.filter(([frame]) => (frame as { type: string }).type === 'baseline').length
}

describe('Session Controller Client apply', () => {
  it('routes Remote events from the $events stream into the object layer and runs handleConnected once per generation', async ({ mock, start }) => {
    const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
    const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
    const { client, sessions } = await bench(start)
    // The first generation's `connection/reset` already ran it; apply itself saw no Host yet.
    await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })

    await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true })
    await vi.waitFor(() => {
      expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 })
    })

    await emit(mock, 'api-session/status', sid('session-1'), true)
    await emit(mock, 'api-session/activity', sid('session-1'), 9)
    await emit(mock, 'api-session/error', sid('session-1'), 'agent failed')
    await vi.waitFor(() => {
      expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: true, updatedAt: 9 })
    })
    expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed')

    await emit(mock, 'api-session/removed', sid('session-1'))
    await vi.waitFor(() => { expect(sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() })

    client.connection.reconnect()
    await mock.streams.opened(EVENTS, 2)
    await vi.waitFor(() => { expect(connected).toHaveBeenCalledTimes(2) })
  }, COLD_BOOT_TIMEOUT_MS)

  it('runs handleConnected at apply when the Host is already connected, as a reload of the row does', async ({ start }) => {
    const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
    const { client } = await bench(start)
    await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
    await client.reload(SELF)
    expect(connected).toHaveBeenCalledTimes(2)
  })

  it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => {
    const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
    const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
    await start()
    await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })
    expect(accept).toHaveBeenCalledWith(BASELINE)

    // One immediate retry while the Host is available reopens the stream, whose script pushes the baseline again.
    mock.streams.fail(CONTROL, new RemoteStreamCarrierError('generation lost'))
    await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
    expect(mock.log.streams(CONTROL)).toHaveLength(2)

    mock.streams.push(CONTROL, BASELINE)
    await vi.waitFor(() => {
      expect(logged).toHaveBeenCalledWith(
        '[session-controller] control stream failed:',
        expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }),
      )
    })
  })

  it('materializes Host-addressed Agent scopes before the Session list arrives', async ({ mock, start }) => {
    const list = Promise.withResolvers<RemoteResult<SessionListValue>>()
    mock.remote.session.list.mockReturnValueOnce(list.promise)
    const { client, sessions } = await bench(start)
    const adapter = client.ctx.typert.contexts.getClient('agent')
    const first = adapter?.resolve(sid('agent-early'))

    expect(first).toBeDefined()
    expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
    expect(adapter?.resolve(sid('agent-early'))).toBe(first)
    list.resolve(ok({ items: [] }))
    await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
  })

  it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => {
    const { client, sessions } = await bench(start)
    await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })

    await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
    await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() })
    const scoped = sessions.scope(sid('agent-1')) as Context
    const adapter = client.ctx.typert.contexts.getClient('agent')
    expect(adapter?.identity(client.ctx)).toBeUndefined()
    expect(adapter?.identity(scoped)).toBe(sid('agent-1'))
    expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)

    await client.unload(SELF)
    expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined()
  })

  it('waits for a Host generation before retrying the control stream', async ({ mock, start }) => {
    const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
    const hostBack = Promise.withResolvers<undefined>()
    let opens = 0
    // The second $events generation stays unready until the test lets the Host answer.
    mock.stream(EVENTS, (_args, stream) => {
      opens += 1
      const ready = { type: 'ready', clientId: `mock-client-${String(opens)}`, host: { home: '/home/mock' } }
      if (opens === 1) stream.push(ready)
      else void hostBack.promise.then(() => { stream.push(ready) })
    })
    const client = await start()
    await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })

    client.connection.reconnect()
    await mock.streams.opened(EVENTS, 2)
    expect(client.connection.generation.getSnapshot()).toBeUndefined()

    mock.streams.fail(CONTROL, new RemoteStreamCarrierError('offline'))
    await client.flush()
    expect(baselines(accept)).toBe(1)
    expect(mock.log.streams(CONTROL)).toHaveLength(1)

    hostBack.resolve(undefined)
    await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
    expect(client.connection.generation.getSnapshot()).toMatchObject({ id: 2 })
  })
})