Merge remote-tracking branch 'origin/feat/send-unify' into xtr/agent-loop-message-machine
# Conflicts: # packages/client/runtime/src/client/sessions/fold-adapter.ts
This commit is contained in:
@@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
export interface UserMessageNode {
|
||||
kind: 'user'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -50,6 +52,8 @@ export interface UserMessageNode {
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
|
||||
time: number
|
||||
turn: number
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
@@ -63,6 +67,8 @@ export interface AssistantMessageNode {
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
@@ -72,6 +78,8 @@ export interface SteeringMessageNode {
|
||||
export interface ContextMessageNode {
|
||||
kind: 'context'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -80,9 +88,13 @@ export interface ContextMessageNode {
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
seq: number
|
||||
/** Unix epoch ms from the tool/result session event. */
|
||||
time: number
|
||||
callId: string
|
||||
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
|
||||
call: { name: string; argsRaw: string } | null
|
||||
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
|
||||
callTime: number | null
|
||||
content: readonly ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
@@ -97,6 +109,8 @@ export interface ToolResultNode {
|
||||
export interface UnknownSurfaceNode {
|
||||
kind: 'unknown'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event when known. */
|
||||
time: number
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
@@ -117,6 +131,8 @@ export interface RunningToolCall {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms when the tool/call event was logged. */
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface CallIndexEntry {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms of the tool/call event. */
|
||||
time: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
@@ -42,22 +44,32 @@ function materializeNode(
|
||||
// user message; only a direct human prompt is a user node.
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
@@ -69,7 +81,10 @@ function materializeNode(
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +203,7 @@ export class FoldAdapter {
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(String(event.data.callId), {
|
||||
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
|
||||
@@ -95,9 +95,10 @@ export class SessionsService {
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open}. Projection validates it against the live list
|
||||
* instead of destructively pruning, so a selection survives transient list
|
||||
* states (reconnect re-pull) and resurfaces when its session returns.
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
@@ -116,7 +117,7 @@ export class SessionsService {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
@@ -137,7 +138,7 @@ export class SessionsService {
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
@@ -148,6 +149,17 @@ export class SessionsService {
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state. Wipes the persisted selection too — a reload stays on empty until
|
||||
* the user opens or starts a session. Staging holds the previous occupant
|
||||
* across the blank (same masked-gap rule as a transient list miss).
|
||||
*/
|
||||
clear(): void {
|
||||
this.selection.set({})
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
@@ -159,6 +171,27 @@ export class SessionsService {
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace folder under the host process cwd and a session in it.
|
||||
* Name is a single path segment (no separators); the host mkdir runs inside
|
||||
* session.create. Caller opens the returned id when it wants the session staged.
|
||||
* @param name - workspace folder basename.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async createWorkspace(name: string): Promise<SessionId> {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
|
||||
if (/[/\\]/.test(trimmed)) {
|
||||
throw new Error('sessions.createWorkspace: name must not contain path separators')
|
||||
}
|
||||
const { result } = await this.api.host.describe({})
|
||||
if (!result.ok) {
|
||||
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
|
||||
return this.create({ cwd: `${hostCwd}/${trimmed}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
|
||||
@@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
case 'tool/call': {
|
||||
this.openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
turn: event.data.turn, step: event.data.step, time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
this.callsRev++
|
||||
@@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
@@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
|
||||
@@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
|
||||
})
|
||||
|
||||
it('clear() blanks list.current and the persisted selection', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
removeItem: (k: string) => { storage.delete(k) },
|
||||
clear: () => { storage.clear() },
|
||||
})
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(storage.get('dsh.sessions.current')).toContain('s1')
|
||||
b.svc.clear()
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
// Persisted wipe: a fresh service with the same storage stays on empty.
|
||||
const again = bench()
|
||||
await feedList(again, [{ id: 's1' }])
|
||||
expect(again.svc.list.getSnapshot().current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('masks (not destroys) the selection while its session is off the list', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
@@ -277,6 +297,27 @@ describe('create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('joins host.describe cwd with the name and creates there', async () => {
|
||||
const b = bench()
|
||||
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
|
||||
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
|
||||
})
|
||||
|
||||
it('rejects empty names and path separators; surfaces describe failures', async () => {
|
||||
const b = bench()
|
||||
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
|
||||
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
|
||||
b.api.onDescribe = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
|
||||
} as never)
|
||||
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
|
||||
Reference in New Issue
Block a user