Merge remote-tracking branch 'origin/master' into codex/status-bar-token-metrics
# Conflicts: # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # docs/architecture.i18n.yaml # docs/event-producer-consumer.md # packages/client/connection/src/client/api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/manager.ts # packages/client/runtime/src/client/sessions/service.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts
This commit is contained in:
@@ -87,6 +87,7 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
@@ -94,6 +95,18 @@ export class FakeApiClient implements IApiClient {
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -115,6 +128,8 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
@@ -159,6 +174,15 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -334,6 +334,28 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
|
||||
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
|
||||
] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
const items = manager.getListSnapshot().items
|
||||
// Cold row: title surfaces straight from the list block — no open, no history.
|
||||
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
|
||||
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
|
||||
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
|
||||
})
|
||||
|
||||
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[] }))
|
||||
@@ -528,3 +550,62 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
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({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
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]?.waitingApproval).toBe(true)
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
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' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
manager.handleDisconnected()
|
||||
// Instantiate after the death sweep: no zombie interaction replays (the
|
||||
// pendingBuffers held only dead-generation rpcIds), so the session mints
|
||||
// no pending waits.
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -273,6 +273,21 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
|
||||
// A fresh session whose only window content is a command pair (plus the
|
||||
// knob events a /permission switch appends — not surface-eligible, so
|
||||
// they never become nodes) stays phase 'blank': selecting a preset from
|
||||
// the hero must not enter the conversation view.
|
||||
const { session } = await opened([])
|
||||
expect(session.getSnapshot().composerPhase).toBe('blank')
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -234,6 +234,29 @@ describe('WorkspacesService', () => {
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBeNull()
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
|
||||
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
|
||||
})
|
||||
|
||||
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
|
||||
api.onListDirectory = () => Promise.resolve(ok(listing))
|
||||
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
|
||||
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
|
||||
// The optional path is omitted from the payload, not sent as undefined.
|
||||
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
|
||||
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
|
||||
const listFailure = workspaces.listDirectory('/x')
|
||||
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
|
||||
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
|
||||
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
|
||||
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
|
||||
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
|
||||
Reference in New Issue
Block a user