refactor: migrate from clientSessionKey to clientId identity
This commit is contained in:
@@ -83,7 +83,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -171,7 +172,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -197,7 +199,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
connectTimeoutMs: 100,
|
||||
@@ -234,7 +237,8 @@ describe('DaemonClient', () => {
|
||||
let transportIndex = 0
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2',
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
clientId: 'clsk_test',
|
||||
logger,
|
||||
reconnect: {
|
||||
enabled: true,
|
||||
@@ -267,42 +271,24 @@ describe('DaemonClient', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('requires explicit relay session identity when URL clientId is missing', () => {
|
||||
test('requires non-empty clientId', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
clientId: '',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay client requires clientSessionKey or URL clientId')
|
||||
}).toThrow('Daemon client requires a non-empty clientId')
|
||||
})
|
||||
|
||||
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
|
||||
test('requires non-empty clientId for direct connections', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws',
|
||||
clientId: ' ',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
})
|
||||
|
||||
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
}).toThrow('Daemon client requires a non-empty clientId')
|
||||
})
|
||||
|
||||
test('logs configured runtime generation in connection transition events', async () => {
|
||||
@@ -310,7 +296,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
runtimeGeneration: 7,
|
||||
@@ -336,7 +323,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -399,7 +387,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -467,7 +456,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -526,7 +516,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -597,7 +588,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -659,7 +651,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -705,7 +698,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -786,7 +780,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -829,7 +824,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -881,7 +877,8 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
@@ -908,7 +905,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -962,7 +960,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1037,7 +1036,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1093,7 +1093,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1139,7 +1140,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1181,7 +1183,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1223,7 +1226,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1285,7 +1289,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1347,7 +1352,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1418,7 +1424,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1470,7 +1477,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1562,7 +1570,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1632,7 +1641,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1668,7 +1678,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AgentResumedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type WSWelcomeMessage,
|
||||
WSOutboundMessageSchema,
|
||||
} from '../shared/messages.js'
|
||||
import type {
|
||||
@@ -144,7 +145,8 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
|
||||
|
||||
export type DaemonClientConfig = {
|
||||
url: string
|
||||
clientSessionKey?: string
|
||||
clientId: string
|
||||
clientType?: 'mobile' | 'browser' | 'cli' | 'mcp'
|
||||
runtimeGeneration?: number | null
|
||||
authHeader?: string
|
||||
suppressSendErrors?: boolean
|
||||
@@ -312,7 +314,7 @@ function isWaiterTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith('Timeout waiting for message')
|
||||
}
|
||||
|
||||
function normalizeClientSessionKey(value: unknown): string | null {
|
||||
function normalizeClientId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
@@ -390,8 +392,9 @@ export class DaemonClient {
|
||||
private terminalStreams: TerminalStreamManager
|
||||
private readonly logConnectionPath: 'direct' | 'relay'
|
||||
private readonly logServerId: string | null
|
||||
private readonly logClientSessionKeyHash: string
|
||||
private readonly logClientIdHash: string
|
||||
private readonly logGeneration: number | null
|
||||
private lastWelcomeMessage: WSWelcomeMessage | null = null
|
||||
|
||||
constructor(private config: DaemonClientConfig) {
|
||||
this.logger = config.logger ?? consoleLogger
|
||||
@@ -402,11 +405,16 @@ export class DaemonClient {
|
||||
} catch {
|
||||
parsedUrlForLog = null
|
||||
}
|
||||
const parsedServerIdForLog = normalizeClientSessionKey(
|
||||
const parsedServerIdForLog = normalizeClientId(
|
||||
parsedUrlForLog?.searchParams.get('serverId')
|
||||
)
|
||||
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null
|
||||
this.logClientSessionKeyHash = 'h_unresolved'
|
||||
const resolvedClientId = normalizeClientId(this.config.clientId)
|
||||
if (!resolvedClientId) {
|
||||
throw new Error('Daemon client requires a non-empty clientId')
|
||||
}
|
||||
this.config.clientId = resolvedClientId
|
||||
this.logClientIdHash = hashForLog(resolvedClientId)
|
||||
this.logGeneration =
|
||||
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration)
|
||||
? this.config.runtimeGeneration
|
||||
@@ -422,48 +430,6 @@ export class DaemonClient {
|
||||
})
|
||||
},
|
||||
})
|
||||
const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey)
|
||||
let parsed: URL | null = null
|
||||
try {
|
||||
parsed = new URL(this.config.url)
|
||||
} catch {
|
||||
// ignore - invalid URL will be handled on connect
|
||||
}
|
||||
|
||||
// Relay requires a clientId so the daemon can create an independent
|
||||
// socket + E2EE channel per connected client.
|
||||
if (isRelayClientWebSocketUrl(this.config.url)) {
|
||||
const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
|
||||
if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
|
||||
throw new Error('Relay clientId and clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientId
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Relay client requires clientSessionKey or URL clientId')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientId) {
|
||||
parsed.searchParams.set('clientId', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey'))
|
||||
if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) {
|
||||
throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
}
|
||||
const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey
|
||||
if (!resolvedClientSessionKey) {
|
||||
throw new Error('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
}
|
||||
this.config.clientSessionKey = resolvedClientSessionKey
|
||||
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
|
||||
if (parsed && !urlClientSessionKey) {
|
||||
parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey)
|
||||
this.config.url = parsed.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -532,8 +498,10 @@ export class DaemonClient {
|
||||
logger: this.logger,
|
||||
})
|
||||
}
|
||||
const transport = transportFactory({ url: this.config.url, headers })
|
||||
const transportUrl = this.resolveTransportUrlForAttempt()
|
||||
const transport = transportFactory({ url: transportUrl, headers })
|
||||
this.transport = transport
|
||||
this.lastWelcomeMessage = null
|
||||
|
||||
this.updateConnectionState({
|
||||
status: 'connecting',
|
||||
@@ -556,18 +524,12 @@ export class DaemonClient {
|
||||
|
||||
this.transportCleanup = [
|
||||
transport.onOpen(() => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = null
|
||||
this.reconnectAttempt = 0
|
||||
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
|
||||
this.resubscribeCheckoutDiffSubscriptions()
|
||||
this.resubscribeTerminalDirectorySubscriptions()
|
||||
this.flushPendingSendQueue()
|
||||
this.resolveConnect()
|
||||
this.sendHelloMessage()
|
||||
}),
|
||||
transport.onClose((event) => {
|
||||
this.resetConnectTimeout()
|
||||
@@ -674,6 +636,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(new Error('Daemon client closed'))
|
||||
this.rejectPendingSendQueue(new Error('Daemon client closed'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.lastWelcomeMessage = null
|
||||
this.updateConnectionState(
|
||||
{ status: 'disposed' },
|
||||
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
|
||||
@@ -2599,6 +2562,45 @@ export class DaemonClient {
|
||||
return requestId ?? crypto.randomUUID()
|
||||
}
|
||||
|
||||
getLastWelcomeMessage(): WSWelcomeMessage | null {
|
||||
return this.lastWelcomeMessage
|
||||
}
|
||||
|
||||
private resolveTransportUrlForAttempt(): string {
|
||||
return this.config.url
|
||||
}
|
||||
|
||||
private sendHelloMessage(): void {
|
||||
if (!this.transport) {
|
||||
this.scheduleReconnect({
|
||||
reason: 'Transport unavailable before hello',
|
||||
event: 'HELLO_TRANSPORT_MISSING',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.transport.send(
|
||||
JSON.stringify({
|
||||
type: 'hello',
|
||||
clientId: this.config.clientId,
|
||||
clientType: this.config.clientType ?? 'cli',
|
||||
protocolVersion: 1,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to send hello message'
|
||||
this.lastErrorValue = message
|
||||
this.scheduleReconnect({
|
||||
reason: message,
|
||||
event: 'HELLO_SEND_FAILED',
|
||||
reasonCode: 'transport_error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private disposeTransport(code = 1001, reason = 'Reconnecting'): void {
|
||||
this.cleanupTransport()
|
||||
if (this.transport) {
|
||||
@@ -2660,7 +2662,7 @@ export class DaemonClient {
|
||||
|
||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
|
||||
if (!parsed.success) {
|
||||
const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? 'unknown'
|
||||
const msgType = (parsedJson as { type?: string })?.type ?? 'unknown'
|
||||
this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed')
|
||||
return
|
||||
}
|
||||
@@ -2669,6 +2671,18 @@ export class DaemonClient {
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.data.type === 'welcome') {
|
||||
this.lastWelcomeMessage = parsed.data
|
||||
this.resetConnectTimeout()
|
||||
this.reconnectAttempt = 0
|
||||
this.updateConnectionState({ status: 'connected' }, { event: 'HELLO_WELCOME' })
|
||||
this.resubscribeCheckoutDiffSubscriptions()
|
||||
this.resubscribeTerminalDirectorySubscriptions()
|
||||
this.flushPendingSendQueue()
|
||||
this.resolveConnect()
|
||||
return
|
||||
}
|
||||
|
||||
this.handleSessionMessage(parsed.data.message)
|
||||
}
|
||||
|
||||
@@ -2704,7 +2718,7 @@ export class DaemonClient {
|
||||
this.logger.info(
|
||||
{
|
||||
serverId: this.logServerId,
|
||||
clientSessionKeyHash: this.logClientSessionKeyHash,
|
||||
clientIdHash: this.logClientIdHash,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
event: metadata?.event ?? 'STATE_UPDATE',
|
||||
@@ -2745,6 +2759,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(new Error(reason ?? 'Connection lost'))
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.lastWelcomeMessage = null
|
||||
|
||||
if (wasDisposed) {
|
||||
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
|
||||
|
||||
@@ -128,26 +128,51 @@ describe("curateAgentActivity", () => {
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
input: { description: "Investigate" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Investigate repository",
|
||||
log: "[Read] README.md",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
toolCallItem({
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
metadata: { subAgentActivity: "Read" },
|
||||
}),
|
||||
toolCallItem({
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
metadata: { subAgentActivity: "Edit" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Investigate repository",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Bash",
|
||||
summary: "ls",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
const lines = result.split("\n");
|
||||
|
||||
expect(lines.filter((line) => line.startsWith("[Task]"))).toEqual(["[Task] Edit"]);
|
||||
expect(lines.filter((line) => line.startsWith("[Explore]"))).toEqual([
|
||||
"[Explore] Investigate repository",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders todo/error/compaction entries", () => {
|
||||
|
||||
@@ -16,6 +16,63 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class EventPushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
|
||||
private closed = false;
|
||||
|
||||
push(value: T): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const resolver = this.resolvers.shift();
|
||||
if (resolver) {
|
||||
resolver({ value, done: false });
|
||||
return;
|
||||
}
|
||||
this.queue.push(value);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.closed = true;
|
||||
while (this.resolvers.length > 0) {
|
||||
const resolver = this.resolvers.shift();
|
||||
resolver?.({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: () => {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift()!;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (this.closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise((resolve) => this.resolvers.push(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -792,6 +849,207 @@ describe("AgentManager", () => {
|
||||
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
test("waitForAgentEvent does not resolve idle until pendingRun is cleared", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const releaseTurnCompleted = deferred<void>();
|
||||
const releaseStreamEnd = deferred<void>();
|
||||
|
||||
class SlowTerminalSession extends TestAgentSession {
|
||||
override async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
await releaseTurnCompleted.promise;
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
await releaseStreamEnd.promise;
|
||||
}
|
||||
}
|
||||
|
||||
class SlowTerminalClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new SlowTerminalSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new SlowTerminalClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000124",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const turnCompletedSeen = new Promise<void>((resolve) => {
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (
|
||||
event.type === "agent_stream" &&
|
||||
event.agentId === snapshot.id &&
|
||||
event.event.type === "turn_completed"
|
||||
) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
);
|
||||
});
|
||||
|
||||
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||
const consumePromise = (async () => {
|
||||
for await (const _event of stream) {
|
||||
// Drain events so manager lifecycle progresses naturally.
|
||||
}
|
||||
})();
|
||||
|
||||
await manager.waitForAgentRunStart(snapshot.id);
|
||||
const waitPromise = manager.waitForAgentEvent(snapshot.id);
|
||||
|
||||
releaseTurnCompleted.resolve();
|
||||
await turnCompletedSeen;
|
||||
const earlyResolution = await Promise.race([
|
||||
waitPromise.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
|
||||
]);
|
||||
expect(earlyResolution).toBe("pending");
|
||||
|
||||
releaseStreamEnd.resolve();
|
||||
const waited = await waitPromise;
|
||||
expect(waited.status).toBe("idle");
|
||||
|
||||
await consumePromise;
|
||||
});
|
||||
|
||||
test("applies live autonomous events while no foreground run is active", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const liveEvents = new EventPushable<AgentStreamEvent>();
|
||||
|
||||
class LiveEventSession extends TestAgentSession {
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
for await (const event of liveEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
liveEvents.end();
|
||||
}
|
||||
}
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new LiveEventSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new LiveEventClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000125",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const lifecycleUpdates: string[] = [];
|
||||
let sawRunningState = false;
|
||||
let resolveSettled!: () => void;
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
resolveSettled = resolve;
|
||||
});
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
lifecycleUpdates.push(event.agent.lifecycle);
|
||||
if (event.agent.lifecycle === "running") {
|
||||
sawRunningState = true;
|
||||
}
|
||||
if (sawRunningState && event.agent.lifecycle === "idle") {
|
||||
resolveSettled();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
);
|
||||
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
liveEvents.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
|
||||
});
|
||||
liveEvents.push({ type: "turn_completed", provider: "codex" });
|
||||
await settled;
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.lifecycle).toBe("idle");
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_PUMP_MESSAGE",
|
||||
});
|
||||
expect(lifecycleUpdates).toContain("running");
|
||||
expect(lifecycleUpdates).toContain("idle");
|
||||
});
|
||||
|
||||
test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const liveEvents = new EventPushable<AgentStreamEvent>();
|
||||
|
||||
class LiveEventSession extends TestAgentSession {
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
for await (const event of liveEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
liveEvents.end();
|
||||
}
|
||||
}
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new LiveEventSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new LiveEventClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
liveEvents.push({ type: "turn_completed", provider: "codex" });
|
||||
|
||||
const result = await waitPromise;
|
||||
expect(result.status).toBe("idle");
|
||||
});
|
||||
|
||||
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -238,6 +238,10 @@ type SubscriptionRecord = {
|
||||
agentId: string | null;
|
||||
};
|
||||
|
||||
type LiveEventStreamingSession = AgentSession & {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
||||
@@ -272,6 +276,16 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function supportsLiveEventStream(
|
||||
session: AgentSession
|
||||
): session is LiveEventStreamingSession {
|
||||
return (
|
||||
"streamLiveEvents" in session &&
|
||||
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents ===
|
||||
"function"
|
||||
);
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly agents = new Map<string, ActiveManagedAgent>();
|
||||
@@ -281,6 +295,7 @@ export class AgentManager {
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly liveEventPumps = new Map<string, Promise<void>>();
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -727,6 +742,7 @@ export class AgentManager {
|
||||
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
try {
|
||||
await existing.session.close();
|
||||
} catch (error) {
|
||||
@@ -756,6 +772,7 @@ export class AgentManager {
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
// Clean up previousStatus to prevent memory leak
|
||||
this.previousStatuses.delete(agentId);
|
||||
const session = agent.session;
|
||||
@@ -1275,6 +1292,7 @@ export class AgentManager {
|
||||
|
||||
let currentStatus: AgentLifecycleStatus = initialStatus;
|
||||
let hasStarted = initialBusy || hasPendingRun;
|
||||
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
||||
|
||||
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
@@ -1337,6 +1355,9 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
if (!waitForActive || hasStarted) {
|
||||
if (terminalStatusOverride) {
|
||||
currentStatus = terminalStatusOverride;
|
||||
}
|
||||
finish(null);
|
||||
}
|
||||
return;
|
||||
@@ -1348,20 +1369,15 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
if (event.event.type === "turn_failed") {
|
||||
currentStatus = "error";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
terminalStatusOverride = "error";
|
||||
return;
|
||||
}
|
||||
if (event.event.type === "turn_completed") {
|
||||
currentStatus = "idle";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
}
|
||||
if (event.event.type === "turn_canceled") {
|
||||
currentStatus = "idle";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1461,6 +1477,7 @@ export class AgentManager {
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed);
|
||||
this.emitState(managed);
|
||||
this.startLiveEventPump(managed);
|
||||
return { ...managed };
|
||||
}
|
||||
|
||||
@@ -1619,9 +1636,16 @@ export class AgentManager {
|
||||
case "turn_completed":
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
if (!agent.pendingRun && agent.lifecycle !== "idle") {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
this.emitState(agent);
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
break;
|
||||
case "turn_failed":
|
||||
if (!agent.pendingRun) {
|
||||
agent.lifecycle = "error";
|
||||
}
|
||||
agent.lastError = event.error;
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
@@ -1637,6 +1661,9 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_canceled":
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
}
|
||||
agent.lastError = undefined;
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
@@ -1651,6 +1678,12 @@ export class AgentManager {
|
||||
}
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_started":
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
}
|
||||
break;
|
||||
case "permission_requested":
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.emitState(agent);
|
||||
@@ -1888,4 +1921,42 @@ export class AgentManager {
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private startLiveEventPump(agent: ActiveManagedAgent): void {
|
||||
if (!supportsLiveEventStream(agent.session)) {
|
||||
return;
|
||||
}
|
||||
const liveSession = agent.session;
|
||||
if (this.liveEventPumps.has(agent.id)) {
|
||||
return;
|
||||
}
|
||||
const pump = (async () => {
|
||||
try {
|
||||
for await (const event of liveSession.streamLiveEvents()) {
|
||||
const current = this.agents.get(agent.id);
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
// Foreground streamAgent owns live prompt events while running.
|
||||
// Background live-event tailing is for autonomous activity while idle.
|
||||
if (current.pendingRun) {
|
||||
continue;
|
||||
}
|
||||
this.handleStreamEvent(current, event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id },
|
||||
"Live event pump failed"
|
||||
);
|
||||
}
|
||||
})();
|
||||
this.liveEventPumps.set(agent.id, pump);
|
||||
pump.finally(() => {
|
||||
const current = this.liveEventPumps.get(agent.id);
|
||||
if (current === pump) {
|
||||
this.liveEventPumps.delete(agent.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,17 @@ export type ToolCallDetail =
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "sub_agent";
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
log: string;
|
||||
actions: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "unknown";
|
||||
input: unknown | null;
|
||||
|
||||
+452
-11
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
@@ -50,6 +53,23 @@ function buildUsage() {
|
||||
};
|
||||
}
|
||||
|
||||
function createPromptUuidReader(prompt: AsyncIterable<unknown>) {
|
||||
const iterator = prompt[Symbol.asyncIterator]();
|
||||
let cached: Promise<string | null> | null = null;
|
||||
return async () => {
|
||||
if (!cached) {
|
||||
cached = iterator.next().then((next) => {
|
||||
if (next.done) {
|
||||
return null;
|
||||
}
|
||||
const value = next.value as { uuid?: unknown } | undefined;
|
||||
return typeof value?.uuid === "string" ? value.uuid : null;
|
||||
});
|
||||
}
|
||||
return cached;
|
||||
};
|
||||
}
|
||||
|
||||
function buildFirstQueryMock(
|
||||
allowOldAssistant: Promise<void>
|
||||
): QueryMock {
|
||||
@@ -108,7 +128,8 @@ function buildFirstQueryMock(
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecondQueryMock(): QueryMock {
|
||||
function buildSecondQueryMock(prompt: AsyncIterable<unknown>): QueryMock {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
@@ -126,6 +147,21 @@ function buildSecondQueryMock(): QueryMock {
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "second prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "interrupt-regression-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
@@ -137,7 +173,7 @@ function buildSecondQueryMock(): QueryMock {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
@@ -188,22 +224,49 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createTimedIteratorReader<T>(params: { iterator: AsyncIterator<T> }) {
|
||||
const { iterator } = params;
|
||||
let pendingNext: Promise<IteratorResult<T>> | null = null;
|
||||
|
||||
return {
|
||||
async nextWithTimeout(timeoutMs: number): Promise<IteratorResult<T>> {
|
||||
if (!pendingNext) {
|
||||
pendingNext = iterator.next();
|
||||
}
|
||||
const timeout = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), timeoutMs);
|
||||
});
|
||||
const outcome = await Promise.race([
|
||||
pendingNext.then((result) => ({ kind: "result" as const, result })),
|
||||
timeout.then(() => ({ kind: "timeout" as const })),
|
||||
]);
|
||||
if (outcome.kind === "timeout") {
|
||||
throw new Error("Timed out waiting for live event");
|
||||
}
|
||||
pendingNext = null;
|
||||
return outcome.result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
beforeEach(() => {
|
||||
const allowOldAssistant = deferred<void>();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const mock = buildFirstQueryMock(allowOldAssistant.promise);
|
||||
sdkMocks.firstQuery = mock;
|
||||
sdkMocks.query.mockImplementation(
|
||||
({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const mock = buildFirstQueryMock(allowOldAssistant.promise);
|
||||
sdkMocks.firstQuery = mock;
|
||||
return mock;
|
||||
}
|
||||
const mock = buildSecondQueryMock(prompt);
|
||||
sdkMocks.secondQuery = mock;
|
||||
return mock;
|
||||
}
|
||||
const mock = buildSecondQueryMock();
|
||||
sdkMocks.secondQuery = mock;
|
||||
return mock;
|
||||
});
|
||||
);
|
||||
sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve();
|
||||
});
|
||||
|
||||
@@ -239,4 +302,382 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await firstTurn.return?.();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores stale task-notification assistant/result events queued before the current prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "task-notification-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "task-123",
|
||||
status: "completed",
|
||||
output_file: "/tmp/task-123.txt",
|
||||
summary: "Codex agent is done",
|
||||
session_id: "task-notification-session",
|
||||
uuid: "task-note-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "STALE_TASK_NOTIFICATION_RESPONSE",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "current prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "task-notification-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "CURRENT_PROMPT_RESPONSE",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("current prompt"));
|
||||
const assistantText = collectAssistantText(events);
|
||||
|
||||
expect(assistantText).toContain("CURRENT_PROMPT_RESPONSE");
|
||||
expect(assistantText).not.toContain("STALE_TASK_NOTIFICATION_RESPONSE");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not terminate the current prompt on a stale pre-prompt result event", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "stale-result-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "current prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "stale-result-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "FRESH_AFTER_STALE_RESULT",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("current prompt"));
|
||||
const assistantText = collectAssistantText(events);
|
||||
|
||||
expect(assistantText).toContain("FRESH_AFTER_STALE_RESULT");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("emits autonomous live events from history tail when Claude wakes itself", async () => {
|
||||
const logger = createTestLogger();
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-project-"));
|
||||
const configDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-config-"));
|
||||
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "live-autonomous-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: projectDir,
|
||||
});
|
||||
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
expect(session.describePersistence()?.sessionId).toBe(
|
||||
"live-autonomous-session"
|
||||
);
|
||||
|
||||
const sanitizedCwd = projectDir.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||
const historyDir = path.join(configDir, "projects", sanitizedCwd);
|
||||
mkdirSync(historyDir, { recursive: true });
|
||||
const historyPath = path.join(historyDir, "live-autonomous-session.jsonl");
|
||||
writeFileSync(historyPath, "", "utf8");
|
||||
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const appendAutonomousBurst = (suffix: string) => {
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: `task-${suffix}`,
|
||||
status: "completed",
|
||||
output_file: "/tmp/out.txt",
|
||||
summary: "Background task finished",
|
||||
uuid: `task-note-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
uuid: `assistant-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
uuid: `result-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
};
|
||||
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
let completed = false;
|
||||
for (let attempt = 0; attempt < 4 && !completed; attempt += 1) {
|
||||
appendAutonomousBurst(String(attempt));
|
||||
while (liveEvents.length < 12) {
|
||||
let next: IteratorResult<AgentStreamEvent>;
|
||||
try {
|
||||
next = await timedReader.nextWithTimeout(2_000);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
if (next.value.type === "turn_completed") {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("AUTONOMOUS_WAKE_RESPONSE")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
} finally {
|
||||
if (previousClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||
}
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "../agent-manager.js";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
|
||||
type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function buildQueryMock(events: unknown[]): QueryMock {
|
||||
let index = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (index >= events.length) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
const value = events[index];
|
||||
index += 1;
|
||||
return { done: false, value };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
stream: AsyncGenerator<AgentStreamEvent>
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function buildTailScenarioEvents(actionCount: number): unknown[] {
|
||||
const actionEvents = Array.from({ length: actionCount }, (_, index) => {
|
||||
const actionNumber = index + 1;
|
||||
return {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-tail-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: actionNumber,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: `sub-read-${actionNumber}`,
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: `file-${actionNumber}.md`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "sidechain-tail-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "task-tail-1",
|
||||
name: "Task",
|
||||
input: {
|
||||
subagent_type: "Explore",
|
||||
description: "Tail latest sub-agent activity",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...actionEvents,
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "task-tail-1",
|
||||
tool_name: "Task",
|
||||
content: "done",
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
||||
sdkMocks.query.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "sidechain-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "task-call-1",
|
||||
name: "Task",
|
||||
input: {
|
||||
subagent_type: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-call-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "sub-read-1",
|
||||
name: "Read",
|
||||
input: {
|
||||
file_path: "README.md",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-call-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "sub-edit-1",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: "src/index.ts",
|
||||
old_string: largeOldText,
|
||||
new_string: "replacement",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_progress",
|
||||
tool_use_id: "sub-edit-1",
|
||||
tool_name: "Edit",
|
||||
parent_tool_use_id: "task-call-1",
|
||||
elapsed_time_seconds: 1,
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "task-call-1",
|
||||
tool_name: "Task",
|
||||
content: "done",
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
});
|
||||
|
||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("delegate work"));
|
||||
await session.close();
|
||||
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call"
|
||||
)
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-call-1");
|
||||
|
||||
expect(timelineToolCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const subAgentUpdates = timelineToolCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
expect(subAgentUpdates.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
expect(latest).toBeDefined();
|
||||
if (!latest || latest.detail.type !== "sub_agent") {
|
||||
throw new Error("expected sub_agent detail");
|
||||
}
|
||||
|
||||
expect(latest.detail.subAgentType).toBe("Explore");
|
||||
expect(latest.detail.description).toBe("Inspect repository structure");
|
||||
expect(latest.detail.actions).toEqual([
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Edit",
|
||||
summary: "src/index.ts",
|
||||
},
|
||||
]);
|
||||
expect(latest.detail.log).toContain("[Read] README.md");
|
||||
expect(latest.detail.log).toContain("[Edit] src/index.ts");
|
||||
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
|
||||
|
||||
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: `2026-02-01T00:00:0${index}.000Z`,
|
||||
item,
|
||||
}));
|
||||
const projected = projectTimelineRows(rows, "claude", "projected");
|
||||
const projectedTaskCalls = projected.filter(
|
||||
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1"
|
||||
);
|
||||
|
||||
expect(projectedTaskCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||
sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
|
||||
const session = await new ClaudeAgentClient({ logger }).createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("delegate work"));
|
||||
await session.close();
|
||||
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call"
|
||||
)
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-tail-1");
|
||||
const subAgentUpdates = timelineToolCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
expect(latest).toBeDefined();
|
||||
if (!latest || latest.detail.type !== "sub_agent") {
|
||||
throw new Error("expected sub_agent detail");
|
||||
}
|
||||
|
||||
expect(latest.detail.actions).toHaveLength(200);
|
||||
expect(latest.detail.actions[0]).toEqual({
|
||||
index: 6,
|
||||
toolName: "Read",
|
||||
summary: "file-6.md",
|
||||
});
|
||||
expect(latest.detail.actions[199]).toEqual({
|
||||
index: 205,
|
||||
toolName: "Read",
|
||||
summary: "file-205.md",
|
||||
});
|
||||
|
||||
expect(latest.detail.log).not.toContain("[Read] file-1.md");
|
||||
expect(latest.detail.log).not.toContain("[Read] file-5.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-6.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-205.md");
|
||||
});
|
||||
});
|
||||
@@ -1264,7 +1264,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
);
|
||||
|
||||
test(
|
||||
"collapses sub-agent tool calls into Task metadata updates",
|
||||
"collapses sub-agent tool calls into Task sub_agent detail updates",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
@@ -1297,19 +1297,13 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
||||
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Sub-agent tool calls (Read, Bash, shell, etc.) should NOT appear as
|
||||
// separate timeline items — they should only appear as Task metadata updates
|
||||
const subAgentLeaks = toolCalls.filter(
|
||||
(item) => item.name !== "Task" && item.metadata?.subAgentActivity === undefined
|
||||
);
|
||||
// If there are non-Task tool calls, they must be from the main agent, not sub-agent
|
||||
// We can't 100% guarantee Claude won't also use tools directly, but Task metadata
|
||||
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
||||
// updates should exist for the sub-agent activity
|
||||
const taskWithMetadata = taskCalls.filter(
|
||||
(item) => item.metadata?.subAgentActivity
|
||||
const taskWithSubAgentDetail = taskCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
if (taskCalls.length > 0) {
|
||||
expect(taskWithMetadata.length).toBeGreaterThanOrEqual(1);
|
||||
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Verify the curator produces clean output with collapsed Task entries
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./claude/tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -341,12 +342,43 @@ type ToolUseCacheEntry = {
|
||||
input?: AgentMetadata | null;
|
||||
};
|
||||
|
||||
type SubAgentActionEntry = {
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
type SubAgentActivityState = {
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
actions: SubAgentActionEntry[];
|
||||
actionKeys: string[];
|
||||
nextActionIndex: number;
|
||||
actionIndexByKey: Map<string, number>;
|
||||
};
|
||||
|
||||
type SubAgentActionCandidate = {
|
||||
key: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
|
||||
function isMetadata(value: unknown): value is AgentMetadata {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isMcpServerConfig(value: unknown): value is McpServerConfig {
|
||||
if (!isMetadata(value)) {
|
||||
return false;
|
||||
@@ -684,10 +716,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
private selectableModelIds: Set<string> | null = null;
|
||||
private activeSidechains = new Map<string, string>();
|
||||
private activeSidechains = new Map<string, SubAgentActivityState>();
|
||||
private compacting = false;
|
||||
private queryRestartNeeded = false;
|
||||
private userMessageIds: string[] = [];
|
||||
private sentUserMessageIds = new Set<string>();
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
config: ClaudeAgentConfig,
|
||||
@@ -887,6 +921,88 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
let currentHistoryPath: string | null = null;
|
||||
let lineCursor = 0;
|
||||
let suppressLocalTurn = false;
|
||||
let autonomousTurnActive = false;
|
||||
|
||||
while (!this.closed) {
|
||||
const sessionId = this.claudeSessionId;
|
||||
const historyPath = sessionId ? this.resolveHistoryPath(sessionId) : null;
|
||||
if (!historyPath) {
|
||||
await this.waitForLiveHistoryPoll();
|
||||
continue;
|
||||
}
|
||||
|
||||
let lines: string[];
|
||||
try {
|
||||
const content = fs.readFileSync(historyPath, "utf8");
|
||||
lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
} catch {
|
||||
await this.waitForLiveHistoryPoll();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHistoryPath !== historyPath) {
|
||||
currentHistoryPath = historyPath;
|
||||
lineCursor = lines.length;
|
||||
suppressLocalTurn = false;
|
||||
autonomousTurnActive = false;
|
||||
await this.waitForLiveHistoryPoll();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineCursor > lines.length) {
|
||||
lineCursor = 0;
|
||||
}
|
||||
if (lineCursor === lines.length) {
|
||||
await this.waitForLiveHistoryPoll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLines = lines.slice(lineCursor);
|
||||
lineCursor = lines.length;
|
||||
for (const rawLine of nextLines) {
|
||||
let entry: any;
|
||||
try {
|
||||
entry = JSON.parse(rawLine);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entry?.isSidechain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry?.type === "user" &&
|
||||
typeof entry.uuid === "string" &&
|
||||
this.sentUserMessageIds.has(entry.uuid)
|
||||
) {
|
||||
this.sentUserMessageIds.delete(entry.uuid);
|
||||
suppressLocalTurn = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (suppressLocalTurn) {
|
||||
if (entry?.type === "result") {
|
||||
suppressLocalTurn = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = this.convertLiveHistoryEntryToEvents({
|
||||
entry,
|
||||
autonomousTurnActive,
|
||||
});
|
||||
autonomousTurnActive = converted.autonomousTurnActive;
|
||||
for (const event of converted.events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return this.availableModes;
|
||||
}
|
||||
@@ -1018,6 +1134,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
this.input?.end();
|
||||
await this.query?.interrupt?.();
|
||||
@@ -1468,6 +1585,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
const messageId = randomUUID();
|
||||
this.rememberUserMessageId(messageId);
|
||||
this.sentUserMessageIds.add(messageId);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
@@ -1504,6 +1622,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.input.push(sdkMessage);
|
||||
const expectedPromptUuid =
|
||||
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0
|
||||
? sdkMessage.uuid
|
||||
: null;
|
||||
let promptActivated = expectedPromptUuid === null;
|
||||
|
||||
while (true) {
|
||||
// Check if this turn has been superseded by a new one.
|
||||
@@ -1524,6 +1647,35 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
|
||||
// Guard against stale queued events from a prior run (for example,
|
||||
// task-notification side activity): do not treat assistant/result as part
|
||||
// of this prompt until we observe a prompt-owned stream start.
|
||||
if (!promptActivated) {
|
||||
const isTopLevelAssistantStart =
|
||||
value.type === "stream_event" &&
|
||||
(value as { event?: { type?: unknown }; parent_tool_use_id?: unknown }).event
|
||||
?.type === "message_start" &&
|
||||
((value as { parent_tool_use_id?: unknown }).parent_tool_use_id ?? null) ===
|
||||
null;
|
||||
if (
|
||||
value.type === "user" &&
|
||||
typeof value.uuid === "string" &&
|
||||
value.uuid === expectedPromptUuid
|
||||
) {
|
||||
promptActivated = true;
|
||||
continue;
|
||||
}
|
||||
if (isTopLevelAssistantStart) {
|
||||
promptActivated = true;
|
||||
} else if (value.type === "system" && value.subtype === "init") {
|
||||
yield value;
|
||||
continue;
|
||||
}
|
||||
if (!promptActivated) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield value;
|
||||
if (value.type === "result") {
|
||||
break;
|
||||
@@ -1617,63 +1769,268 @@ class ClaudeAgentSession implements AgentSession {
|
||||
message: SDKMessage,
|
||||
parentToolUseId: string
|
||||
): AgentStreamEvent[] {
|
||||
let toolName: string | undefined;
|
||||
const state =
|
||||
this.activeSidechains.get(parentToolUseId) ??
|
||||
({
|
||||
actions: [],
|
||||
actionKeys: [],
|
||||
nextActionIndex: 1,
|
||||
actionIndexByKey: new Map<string, number>(),
|
||||
} satisfies SubAgentActivityState);
|
||||
this.activeSidechains.set(parentToolUseId, state);
|
||||
|
||||
if (message.type === "assistant") {
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (isClaudeContentChunk(block) &&
|
||||
(block.type === "tool_use" || block.type === "mcp_tool_use" || block.type === "server_tool_use") &&
|
||||
typeof block.name === "string"
|
||||
) {
|
||||
toolName = block.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(
|
||||
state,
|
||||
parentToolUseId
|
||||
);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
let actionUpdated = false;
|
||||
for (const action of actionCandidates) {
|
||||
if (this.appendSubAgentAction(state, action)) {
|
||||
actionUpdated = true;
|
||||
}
|
||||
} else if (message.type === "stream_event") {
|
||||
const event = message.event;
|
||||
if (event.type === "content_block_start") {
|
||||
const cb = isClaudeContentChunk(event.content_block) ? event.content_block : null;
|
||||
if (cb?.type === "tool_use" && typeof cb.name === "string") {
|
||||
toolName = cb.name;
|
||||
}
|
||||
}
|
||||
} else if (message.type === "tool_progress") {
|
||||
toolName = message.tool_name;
|
||||
}
|
||||
|
||||
if (!toolName) {
|
||||
if (!contextUpdated && !actionUpdated) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prev = this.activeSidechains.get(parentToolUseId);
|
||||
if (prev === toolName) {
|
||||
return [];
|
||||
}
|
||||
this.activeSidechains.set(parentToolUseId, toolName);
|
||||
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
metadata: { subAgentActivity: toolName },
|
||||
});
|
||||
if (!toolCall) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary
|
||||
? `[${action.toolName}] ${action.summary}`
|
||||
: `[${action.toolName}]`
|
||||
)
|
||||
.join("\n"),
|
||||
actions: state.actions.map((action) => ({
|
||||
index: action.index,
|
||||
toolName: action.toolName,
|
||||
...(action.summary ? { summary: action.summary } : {}),
|
||||
})),
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
type: "timeline",
|
||||
item: toolCall,
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
provider: "claude",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private updateSubAgentContextFromTaskInput(
|
||||
state: SubAgentActivityState,
|
||||
parentToolUseId: string
|
||||
): boolean {
|
||||
const taskInput = this.toolUseCache.get(parentToolUseId)?.input;
|
||||
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
||||
|
||||
let changed = false;
|
||||
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
|
||||
state.subAgentType = nextSubAgentType;
|
||||
changed = true;
|
||||
}
|
||||
if (nextDescription && nextDescription !== state.description) {
|
||||
state.description = nextDescription;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private normalizeSubAgentText(value: unknown): string | undefined {
|
||||
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(
|
||||
message: SDKMessage
|
||||
): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
const content = message.message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
const actions: SubAgentActionCandidate[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
!isClaudeContentChunk(block) ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`assistant:${block.name}:${actions.length}`;
|
||||
actions.push({
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (message.type === "stream_event") {
|
||||
const event = message.event;
|
||||
if (event.type !== "content_block_start") {
|
||||
return [];
|
||||
}
|
||||
const block = isClaudeContentChunk(event.content_block)
|
||||
? event.content_block
|
||||
: null;
|
||||
if (
|
||||
!block ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (message.type === "tool_progress") {
|
||||
const toolName = readTrimmedString(message.tool_name);
|
||||
if (!toolName) {
|
||||
return [];
|
||||
}
|
||||
const key =
|
||||
readTrimmedString(message.tool_use_id) ?? `progress:${toolName}`;
|
||||
return [{ key, toolName, input: null }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private appendSubAgentAction(
|
||||
state: SubAgentActivityState,
|
||||
candidate: SubAgentActionCandidate
|
||||
): boolean {
|
||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||
if (!normalizedToolName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = this.deriveSubAgentActionSummary(
|
||||
normalizedToolName,
|
||||
candidate.input
|
||||
);
|
||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = state.actions[existingIndex];
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
const nextSummary = existing.summary ?? summary;
|
||||
const unchanged =
|
||||
existing.toolName === normalizedToolName &&
|
||||
existing.summary === nextSummary;
|
||||
if (unchanged) {
|
||||
return false;
|
||||
}
|
||||
state.actions[existingIndex] = {
|
||||
...existing,
|
||||
toolName: normalizedToolName,
|
||||
...(nextSummary ? { summary: nextSummary } : {}),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextEntry: SubAgentActionEntry = {
|
||||
index: state.nextActionIndex,
|
||||
toolName: normalizedToolName,
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
state.nextActionIndex += 1;
|
||||
state.actions.push(nextEntry);
|
||||
state.actionKeys.push(candidate.key);
|
||||
this.trimSubAgentTail(state);
|
||||
this.rebuildSubAgentActionIndex(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
private trimSubAgentTail(state: SubAgentActivityState): void {
|
||||
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
||||
state.actions.shift();
|
||||
state.actionKeys.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
|
||||
state.actionIndexByKey.clear();
|
||||
for (let index = 0; index < state.actionKeys.length; index += 1) {
|
||||
const key = state.actionKeys[index];
|
||||
if (key) {
|
||||
state.actionIndexByKey.set(key, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveSubAgentActionSummary(
|
||||
toolName: string,
|
||||
input: unknown
|
||||
): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
input,
|
||||
output: null,
|
||||
});
|
||||
if (!runningToolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: runningToolCall.name,
|
||||
status: runningToolCall.status,
|
||||
error: runningToolCall.error,
|
||||
detail: runningToolCall.detail,
|
||||
metadata: runningToolCall.metadata,
|
||||
});
|
||||
return this.normalizeSubAgentText(display.summary);
|
||||
}
|
||||
|
||||
private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] {
|
||||
const parentToolUseId = "parent_tool_use_id" in message
|
||||
? (message as { parent_tool_use_id: string | null }).parent_tool_use_id
|
||||
@@ -1983,6 +2340,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
this.toolUseCache.clear();
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
private pushToolCall(
|
||||
@@ -2023,6 +2381,60 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private waitForLiveHistoryPoll(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
private convertLiveHistoryEntryToEvents(params: {
|
||||
entry: any;
|
||||
autonomousTurnActive: boolean;
|
||||
}): {
|
||||
events: AgentStreamEvent[];
|
||||
autonomousTurnActive: boolean;
|
||||
} {
|
||||
const { entry, autonomousTurnActive } = params;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
if (entry?.type === "result") {
|
||||
if (!autonomousTurnActive) {
|
||||
return { events, autonomousTurnActive: false };
|
||||
}
|
||||
const usage =
|
||||
entry && typeof entry === "object" && entry.usage
|
||||
? this.convertUsage(entry as SDKResultMessage)
|
||||
: undefined;
|
||||
if (entry?.subtype === "success") {
|
||||
events.push({ type: "turn_completed", provider: "claude", usage });
|
||||
} else {
|
||||
const errorMessage =
|
||||
Array.isArray(entry?.errors) && entry.errors.length > 0
|
||||
? entry.errors.join("\n")
|
||||
: "Claude run failed";
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
return { events, autonomousTurnActive: false };
|
||||
}
|
||||
|
||||
let nextAutonomousActive = autonomousTurnActive;
|
||||
const timelineItems = this.convertHistoryEntry(entry);
|
||||
if (!nextAutonomousActive && timelineItems.length > 0) {
|
||||
events.push({ type: "turn_started", provider: "claude" });
|
||||
nextAutonomousActive = true;
|
||||
}
|
||||
for (const item of timelineItems) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
autonomousTurnActive: nextAutonomousActive,
|
||||
};
|
||||
}
|
||||
|
||||
private loadPersistedHistory(sessionId: string) {
|
||||
try {
|
||||
const historyPath = this.resolveHistoryPath(sessionId);
|
||||
@@ -2207,6 +2619,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (typeof block.tool_use_id === "string") {
|
||||
this.toolUseCache.delete(block.tool_use_id);
|
||||
this.activeSidechains.delete(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DaemonClient,
|
||||
} from "./test-utils/index.js";
|
||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||
import {
|
||||
chunkPcm16,
|
||||
parsePcm16MonoWav,
|
||||
@@ -280,24 +279,17 @@ describe("daemon client E2E", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("emits server_info on websocket connect", async () => {
|
||||
test("receives welcome on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
clientId: `cid-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
|
||||
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
|
||||
const unsubscribe = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = parseServerInfoStatusPayload(message.payload);
|
||||
if (!payload) return;
|
||||
resolve({ serverId: payload.serverId });
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
const info = await infoPromise;
|
||||
expect(info.serverId.length).toBeGreaterThan(0);
|
||||
const welcome = client.getLastWelcomeMessage();
|
||||
expect(welcome).not.toBeNull();
|
||||
expect(welcome?.serverId.length).toBeGreaterThan(0);
|
||||
expect(typeof welcome?.resumed).toBe("boolean");
|
||||
|
||||
await client.close();
|
||||
}, 15000);
|
||||
@@ -315,38 +307,20 @@ describe("daemon client E2E", () => {
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`,
|
||||
clientId: `cid-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
|
||||
try {
|
||||
const infoPromise = waitForSignal<{
|
||||
dictationEnabled: boolean;
|
||||
dictationReason: string;
|
||||
voiceEnabled: boolean;
|
||||
voiceReason: string;
|
||||
}>(5000, (resolve) => {
|
||||
const unsubscribe = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = parseServerInfoStatusPayload(message.payload);
|
||||
if (!payload) return;
|
||||
const voice = payload.capabilities?.voice;
|
||||
if (!voice) return;
|
||||
resolve({
|
||||
dictationEnabled: voice.dictation.enabled,
|
||||
dictationReason: voice.dictation.reason,
|
||||
voiceEnabled: voice.voice.enabled,
|
||||
voiceReason: voice.voice.reason,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
const info = await infoPromise;
|
||||
const welcome = client.getLastWelcomeMessage();
|
||||
const voice = welcome?.capabilities?.voice;
|
||||
expect(voice).toBeTruthy();
|
||||
|
||||
expect(info.dictationEnabled).toBe(false);
|
||||
expect(info.dictationReason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(info.voiceEnabled).toBe(false);
|
||||
expect(info.voiceReason).toBe("Realtime voice is disabled in daemon config.");
|
||||
expect(voice?.dictation.enabled).toBe(false);
|
||||
expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(voice?.voice.enabled).toBe(false);
|
||||
expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await isolatedCtx.cleanup();
|
||||
|
||||
@@ -32,15 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
|
||||
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
|
||||
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
|
||||
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
|
||||
const CLIENT_SESSION_KEY = "clsk_checkout_debug";
|
||||
const CLIENT_ID = "clsk_checkout_debug";
|
||||
|
||||
async function testMultiAgentSequence() {
|
||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`,
|
||||
clientSessionKey: CLIENT_SESSION_KEY,
|
||||
url: DAEMON_URL,
|
||||
clientId: CLIENT_ID,
|
||||
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -198,13 +198,12 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
|
||||
const clientId = `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
const ws = new WebSocket(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
serverId,
|
||||
role: "client",
|
||||
clientId,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -234,6 +233,8 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
|
||||
const channel = await createClientChannel(
|
||||
transport,
|
||||
daemonPublicKeyB64,
|
||||
@@ -242,8 +243,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
try {
|
||||
const payload =
|
||||
typeof data === "string" ? JSON.parse(data) : data;
|
||||
// The daemon may send an initial `server_info` status message
|
||||
// immediately upon connect; ignore everything until we see `pong`.
|
||||
if (payload && typeof payload === "object" && (payload as any).type === "welcome") {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
void channelRef.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload && typeof payload === "object" && (payload as any).type === "pong") {
|
||||
clearTimeout(timeout);
|
||||
resolve(payload);
|
||||
@@ -260,7 +266,15 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
},
|
||||
}
|
||||
);
|
||||
await channel.send(JSON.stringify({ type: "ping" }));
|
||||
channelRef = channel;
|
||||
await channel.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: stableClientId,
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
@@ -316,9 +330,9 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
serverId,
|
||||
role: "client",
|
||||
clientId: `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`,
|
||||
})
|
||||
);
|
||||
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const received = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -346,9 +360,18 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
|
||||
const channel = await createClientChannel(transport, daemonPublicKeyB64, {
|
||||
onmessage: (data) => {
|
||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
||||
if (payload && typeof payload === "object" && (payload as any).type === "welcome") {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
void channelRef.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload && typeof payload === "object" && (payload as any).type === "pong") {
|
||||
clearTimeout(timeout);
|
||||
resolve(payload);
|
||||
@@ -360,7 +383,15 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
await channel.send(JSON.stringify({ type: "ping" }));
|
||||
channelRef = channel;
|
||||
await channel.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: stableClientId,
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
|
||||
+28
-13
@@ -67,9 +67,19 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const applySnapshot = (
|
||||
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"]
|
||||
) => {
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
};
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
@@ -78,19 +88,24 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
|
||||
if (message.payload.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
applySnapshot(message.payload.agent);
|
||||
});
|
||||
|
||||
const initial = await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const hydratedSnapshot = initial.entries.find(
|
||||
(candidate) => candidate.agent.id === agent.id
|
||||
)?.agent;
|
||||
if (hydratedSnapshot) {
|
||||
applySnapshot(hydratedSnapshot);
|
||||
}
|
||||
|
||||
await secondary.waitForFinish(agent.id, 180_000);
|
||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
||||
if (finalSnapshot) {
|
||||
applySnapshot(finalSnapshot);
|
||||
}
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// not-running -> running transition.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
||||
|
||||
function snapshot(status: "running" | "idle", updatedAtMs: number) {
|
||||
return {
|
||||
status,
|
||||
updatedAt: new Date(updatedAtMs).toISOString(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("applyAgentInputProcessingTransition", () => {
|
||||
test("clears processing for stale non-running snapshot after reconnect", () => {
|
||||
const result = applyAgentInputProcessingTransition({
|
||||
snapshot: snapshot("idle", 1_000),
|
||||
currentIsProcessing: true,
|
||||
previousIsRunning: true,
|
||||
latestUpdatedAt: 2_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
isProcessing: false,
|
||||
previousIsRunning: false,
|
||||
latestUpdatedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps processing for stale running snapshot", () => {
|
||||
const result = applyAgentInputProcessingTransition({
|
||||
snapshot: snapshot("running", 1_000),
|
||||
currentIsProcessing: true,
|
||||
previousIsRunning: true,
|
||||
latestUpdatedAt: 2_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
isProcessing: true,
|
||||
previousIsRunning: true,
|
||||
latestUpdatedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears processing when fresh update stops running", () => {
|
||||
const result = applyAgentInputProcessingTransition({
|
||||
snapshot: snapshot("idle", 3_000),
|
||||
currentIsProcessing: true,
|
||||
previousIsRunning: true,
|
||||
latestUpdatedAt: 2_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
isProcessing: false,
|
||||
previousIsRunning: false,
|
||||
latestUpdatedAt: 3_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,18 @@ export function applyAgentInputProcessingTransition(input: {
|
||||
latestUpdatedAt: number;
|
||||
}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } {
|
||||
const updatedAt = new Date(input.snapshot.updatedAt).getTime();
|
||||
const isRunning = input.snapshot.status === "running";
|
||||
if (updatedAt < input.latestUpdatedAt) {
|
||||
// Reconnect flows can deliver an authoritative non-running snapshot
|
||||
// whose timestamp predates this client's local "processing started" time.
|
||||
// Clear processing to avoid getting stuck even when we miss an edge transition.
|
||||
if (input.currentIsProcessing && !isRunning) {
|
||||
return {
|
||||
isProcessing: false,
|
||||
previousIsRunning: false,
|
||||
latestUpdatedAt: input.latestUpdatedAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isProcessing: input.currentIsProcessing,
|
||||
previousIsRunning: input.previousIsRunning,
|
||||
@@ -15,7 +26,6 @@ export function applyAgentInputProcessingTransition(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const isRunning = input.snapshot.status === "running";
|
||||
const wasRunning = input.previousIsRunning;
|
||||
let isProcessing = input.currentIsProcessing;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
await primary.sendMessage(agent.id, "Run: sleep 30");
|
||||
await primary.sendMessage(agent.id, "Run: sleep 5");
|
||||
await primary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
@@ -64,9 +64,19 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const applySnapshot = (
|
||||
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"]
|
||||
) => {
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
};
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
@@ -75,19 +85,24 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
if (message.payload.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
applySnapshot(message.payload.agent);
|
||||
});
|
||||
|
||||
const initial = await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const hydratedSnapshot = initial.entries.find(
|
||||
(candidate) => candidate.agent.id === agent.id
|
||||
)?.agent;
|
||||
if (hydratedSnapshot) {
|
||||
applySnapshot(hydratedSnapshot);
|
||||
}
|
||||
|
||||
await secondary.waitForFinish(agent.id, 120_000);
|
||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
||||
if (finalSnapshot) {
|
||||
applySnapshot(finalSnapshot);
|
||||
}
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// not-running -> running transition.
|
||||
|
||||
@@ -382,17 +382,7 @@ async function runUiScenario(params: {
|
||||
}
|
||||
|
||||
if (action.type === "wait_for_finish") {
|
||||
const startedAt = Date.now();
|
||||
let result = await client.waitForFinish(agentId, action.timeoutMs);
|
||||
while (
|
||||
result.status === "idle" &&
|
||||
result.final?.status === "running" &&
|
||||
Date.now() - startedAt < action.timeoutMs
|
||||
) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = Math.max(1_000, action.timeoutMs - elapsed);
|
||||
result = await client.waitForFinish(agentId, remaining);
|
||||
}
|
||||
const result = await client.waitForFinish(agentId, action.timeoutMs);
|
||||
|
||||
expect(
|
||||
result.status,
|
||||
|
||||
@@ -126,4 +126,64 @@ describe("waitForFinish edge cases", () => {
|
||||
rmSync(cwd1, { recursive: true, force: true });
|
||||
rmSync(cwd2, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("waitForFinish keeps status and final snapshot coherent when a new run starts at idle edge", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Idle Edge Coherence",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
|
||||
let sawRunning = false;
|
||||
let spawnedSecondRun = false;
|
||||
let secondRunDrain: Promise<void> | null = null;
|
||||
const unsubscribe = ctx.daemon.daemon.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_state" || event.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
if (event.agent.lifecycle === "running") {
|
||||
sawRunning = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.agent.lifecycle !== "idle" ||
|
||||
!sawRunning ||
|
||||
spawnedSecondRun
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnedSecondRun = true;
|
||||
const stream = ctx.daemon.daemon.agentManager.streamAgent(
|
||||
agent.id,
|
||||
"Use your shell tool to run sleep 1 and then reply done."
|
||||
);
|
||||
secondRunDrain = (async () => {
|
||||
for await (const _event of stream) {
|
||||
// Drain second run so manager can settle.
|
||||
}
|
||||
})();
|
||||
},
|
||||
{ agentId: agent.id, replayState: false }
|
||||
);
|
||||
|
||||
try {
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly: first");
|
||||
const state = await ctx.client.waitForFinish(agent.id, 30000);
|
||||
|
||||
expect(spawnedSecondRun).toBe(true);
|
||||
expect(state.status).toBe("idle");
|
||||
expect(state.final?.status).toBe("idle");
|
||||
expect(state.error).toBeNull();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await secondRunDrain;
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("relay-transport control lifecycle", () => {
|
||||
const control = MockWebSocket.instances[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
control.message(JSON.stringify({ type: "client_connected", clientId: "clt_test" }));
|
||||
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
|
||||
|
||||
const dataSocket = MockWebSocket.instances[1];
|
||||
expect(dataSocket).toBeDefined();
|
||||
|
||||
@@ -32,9 +32,9 @@ type RelaySocketLike = {
|
||||
};
|
||||
|
||||
type ControlMessage =
|
||||
| { type: "sync"; clientIds: string[] }
|
||||
| { type: "client_connected"; clientId: string }
|
||||
| { type: "client_disconnected"; clientId: string }
|
||||
| { type: "sync"; connectionIds: string[] }
|
||||
| { type: "connected"; connectionId: string }
|
||||
| { type: "disconnected"; connectionId: string }
|
||||
| { type: "ping" }
|
||||
| { type: "pong" };
|
||||
|
||||
@@ -50,15 +50,15 @@ function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
if (parsed.type === "ping") return { type: "ping" };
|
||||
if (parsed.type === "pong") return { type: "pong" };
|
||||
if (parsed.type === "sync" && Array.isArray(parsed.clientIds)) {
|
||||
const clientIds = parsed.clientIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
|
||||
return { type: "sync", clientIds };
|
||||
if (parsed.type === "sync" && Array.isArray(parsed.connectionIds)) {
|
||||
const connectionIds = parsed.connectionIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
|
||||
return { type: "sync", connectionIds };
|
||||
}
|
||||
if (parsed.type === "client_connected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
|
||||
return { type: "client_connected", clientId: parsed.clientId.trim() };
|
||||
if (parsed.type === "connected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
|
||||
return { type: "connected", connectionId: parsed.connectionId.trim() };
|
||||
}
|
||||
if (parsed.type === "client_disconnected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
|
||||
return { type: "client_disconnected", clientId: parsed.clientId.trim() };
|
||||
if (parsed.type === "disconnected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
|
||||
return { type: "disconnected", connectionId: parsed.connectionId.trim() };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -79,7 +79,7 @@ export function startRelayTransport({
|
||||
let controlWs: WebSocket | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
||||
const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws
|
||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
@@ -256,24 +256,24 @@ export function startRelayTransport({
|
||||
}
|
||||
if (msg.type === "pong") return;
|
||||
if (msg.type === "sync") {
|
||||
for (const clientId of msg.clientIds) {
|
||||
ensureClientDataSocket(clientId);
|
||||
for (const connectionId of msg.connectionIds) {
|
||||
ensureClientDataSocket(connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "client_connected") {
|
||||
ensureClientDataSocket(msg.clientId);
|
||||
if (msg.type === "connected") {
|
||||
ensureClientDataSocket(msg.connectionId);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "client_disconnected") {
|
||||
const existing = dataSockets.get(msg.clientId);
|
||||
if (msg.type === "disconnected") {
|
||||
const existing = dataSockets.get(msg.connectionId);
|
||||
if (existing) {
|
||||
try {
|
||||
existing.close(1001, "Client disconnected");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dataSockets.delete(msg.clientId);
|
||||
dataSockets.delete(msg.connectionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -291,25 +291,25 @@ export function startRelayTransport({
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const ensureClientDataSocket = (clientId: string): void => {
|
||||
const ensureClientDataSocket = (routingId: string): void => {
|
||||
if (stopped) return;
|
||||
if (!clientId) return;
|
||||
if (dataSockets.has(clientId)) return;
|
||||
if (!routingId) return;
|
||||
if (dataSockets.has(routingId)) return;
|
||||
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
role: "server",
|
||||
clientId,
|
||||
connectionId: routingId,
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
dataSockets.set(clientId, socket);
|
||||
dataSockets.set(routingId, socket);
|
||||
|
||||
let attached = false;
|
||||
const openTimeout = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
if (socket.readyState === WebSocket.OPEN) return;
|
||||
relayLogger.warn({ url, clientId }, "relay_data_open_timeout_terminating");
|
||||
relayLogger.warn({ url, routingId }, "relay_data_open_timeout_terminating");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -319,18 +319,17 @@ export function startRelayTransport({
|
||||
|
||||
socket.on("open", () => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.info({ url, clientId }, "relay_data_connected");
|
||||
relayLogger.info({ url, routingId }, "relay_data_connected");
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
const externalMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `session:${clientId}`,
|
||||
};
|
||||
if (daemonKeyPair) {
|
||||
void attachEncryptedSocket(
|
||||
socket,
|
||||
daemonKeyPair,
|
||||
relayLogger.child({ clientId }),
|
||||
relayLogger.child({ routingId }),
|
||||
attachSocket,
|
||||
externalMetadata
|
||||
);
|
||||
@@ -342,16 +341,16 @@ export function startRelayTransport({
|
||||
socket.on("close", (code, reason) => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url, clientId },
|
||||
{ code, reason: reason?.toString?.(), url, routingId },
|
||||
"relay_data_disconnected"
|
||||
);
|
||||
if (dataSockets.get(clientId) === socket) {
|
||||
dataSockets.delete(clientId);
|
||||
if (dataSockets.get(routingId) === socket) {
|
||||
dataSockets.delete(routingId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
relayLogger.warn({ err, url, clientId }, "relay_data_error");
|
||||
relayLogger.warn({ err, url, routingId }, "relay_data_error");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5421,16 +5421,29 @@ export class Session {
|
||||
}, effectiveTimeoutMs)
|
||||
|
||||
try {
|
||||
const result = await this.agentManager.waitForAgentEvent(agentId, {
|
||||
let result = await this.agentManager.waitForAgentEvent(agentId, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
const final = await this.getAgentPayloadById(agentId)
|
||||
let final = await this.getAgentPayloadById(agentId)
|
||||
if (!final) {
|
||||
throw new Error(`Agent ${agentId} disappeared while waiting`)
|
||||
}
|
||||
|
||||
const status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
|
||||
let status: 'permission' | 'error' | 'idle' =
|
||||
result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
|
||||
|
||||
// Keep wait_for_finish coherent: never return idle while the final snapshot is running.
|
||||
// This can happen if a new run starts between wait resolution and final snapshot fetch.
|
||||
while (status === 'idle' && final.status === 'running') {
|
||||
result = await this.agentManager.waitForAgentEvent(agentId, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
final = await this.getAgentPayloadById(agentId)
|
||||
if (!final) {
|
||||
throw new Error(`Agent ${agentId} disappeared while waiting`)
|
||||
}
|
||||
status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'wait_for_finish_response',
|
||||
|
||||
@@ -11,24 +11,24 @@ import {
|
||||
|
||||
export type DaemonClientConfig = Omit<
|
||||
SharedDaemonClientConfig,
|
||||
"webSocketFactory" | "transportFactory"
|
||||
"webSocketFactory" | "transportFactory" | "clientId"
|
||||
>;
|
||||
export type CreateAgentOptions = CreateAgentRequestOptions;
|
||||
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
|
||||
|
||||
let testClientCounter = 0;
|
||||
|
||||
function nextTestClientSessionKey(): string {
|
||||
function nextTestClientId(): string {
|
||||
testClientCounter += 1;
|
||||
return `clsk_test_client_${testClientCounter}`;
|
||||
return `clid_test_client_${testClientCounter}`;
|
||||
}
|
||||
|
||||
export class DaemonClient extends SharedDaemonClient {
|
||||
constructor(config: DaemonClientConfig) {
|
||||
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey();
|
||||
const clientId = nextTestClientId();
|
||||
super({
|
||||
...config,
|
||||
clientSessionKey,
|
||||
clientId,
|
||||
webSocketFactory: (url, options) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ vi.mock("./push/push-service.js", () => ({
|
||||
|
||||
import {
|
||||
VoiceAssistantWebSocketServer,
|
||||
type ExternalSocketMetadata,
|
||||
} from "./websocket-server";
|
||||
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
@@ -242,6 +241,65 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
|
||||
};
|
||||
}
|
||||
|
||||
function createHelloMessage(clientId: string) {
|
||||
return {
|
||||
type: "hello" as const,
|
||||
clientId,
|
||||
clientType: "cli" as const,
|
||||
protocolVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectRequest() {
|
||||
return {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws",
|
||||
};
|
||||
}
|
||||
|
||||
async function attachRelayAndHello(params: {
|
||||
server: VoiceAssistantWebSocketServer;
|
||||
socket: MockSocket;
|
||||
clientId: string;
|
||||
}) {
|
||||
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
|
||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||
await Promise.resolve();
|
||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
||||
const welcome = JSON.parse(params.socket.sent[0] as string) as {
|
||||
type?: unknown;
|
||||
resumed?: unknown;
|
||||
serverId?: unknown;
|
||||
};
|
||||
expect(welcome.type).toBe("welcome");
|
||||
return welcome;
|
||||
}
|
||||
|
||||
async function attachDirectAndHello(params: {
|
||||
server: VoiceAssistantWebSocketServer;
|
||||
socket: MockSocket;
|
||||
clientId: string;
|
||||
}) {
|
||||
await (params.server as any).attachSocket(params.socket, createDirectRequest());
|
||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||
await Promise.resolve();
|
||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
||||
const welcome = JSON.parse(params.socket.sent[0] as string) as {
|
||||
type?: unknown;
|
||||
resumed?: unknown;
|
||||
serverId?: unknown;
|
||||
};
|
||||
expect(welcome.type).toBe("welcome");
|
||||
return welcome;
|
||||
}
|
||||
|
||||
describe("relay external socket reconnect behavior", () => {
|
||||
beforeEach(() => {
|
||||
sessionMock.instances.length = 0;
|
||||
@@ -254,13 +312,15 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("keeps the same session when relay reconnects within grace window", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-1",
|
||||
};
|
||||
const clientId = "cid-relay-reconnect";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
const firstWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: socket1,
|
||||
clientId,
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -269,7 +329,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
const socket2 = new MockSocket();
|
||||
await server.attachExternalSocket(socket2, metadata);
|
||||
const secondWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: socket2,
|
||||
clientId,
|
||||
});
|
||||
expect(secondWelcome.resumed).toBe(true);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -278,19 +343,8 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects direct socket attach when clientSessionKey is missing", async () => {
|
||||
test("closes pending connection when hello timeout elapses", async () => {
|
||||
const server = createServer();
|
||||
const request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
let closeCode: number | null = null;
|
||||
@@ -300,63 +354,105 @@ describe("relay external socket reconnect behavior", () => {
|
||||
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
|
||||
});
|
||||
|
||||
await (server as any).attachSocket(socket, request);
|
||||
await (server as any).attachSocket(socket, createDirectRequest());
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
expect(closeCode).toBe(1008);
|
||||
expect(closeReason).toBe("Missing clientSessionKey");
|
||||
expect(closeCode).toBe(4001);
|
||||
expect(closeReason).toBe("Hello timeout");
|
||||
expect(sessionMock.instances).toHaveLength(0);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("attaches multiple sockets to the same relay external session key", async () => {
|
||||
test("marks hello as resumed when clientId already has a session", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-multi",
|
||||
};
|
||||
const clientId = "cid-resume-flag";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const socket2 = new MockSocket();
|
||||
await server.attachExternalSocket(socket2, metadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
const onMessage = session.args.onMessage as
|
||||
| ((msg: { type: "status"; payload: { status: string } }) => void)
|
||||
| undefined;
|
||||
expect(onMessage).toBeTypeOf("function");
|
||||
|
||||
onMessage?.({
|
||||
type: "status",
|
||||
payload: { status: "ok" },
|
||||
const firstSocket = new MockSocket();
|
||||
const firstWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: firstSocket,
|
||||
clientId,
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
|
||||
expect(socket1.sent.length).toBeGreaterThan(1);
|
||||
expect(socket2.sent.length).toBeGreaterThan(1);
|
||||
firstSocket.emit("close", 1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
const secondSocket = new MockSocket();
|
||||
const secondWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: secondSocket,
|
||||
clientId,
|
||||
});
|
||||
expect(secondWelcome.resumed).toBe(true);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reuses direct session when clientSessionKey reconnects within grace window", async () => {
|
||||
test("marks hello as not resumed for new clientIds", async () => {
|
||||
const server = createServer();
|
||||
const request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws?clientSessionKey=clsk_direct_reconnect",
|
||||
};
|
||||
|
||||
const firstSocket = new MockSocket();
|
||||
const firstWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: firstSocket,
|
||||
clientId: "cid-new-1",
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
|
||||
const secondSocket = new MockSocket();
|
||||
const secondWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: secondSocket,
|
||||
clientId: "cid-new-2",
|
||||
});
|
||||
expect(secondWelcome.resumed).toBe(false);
|
||||
expect(sessionMock.instances).toHaveLength(2);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects session messages before hello", async () => {
|
||||
const server = createServer();
|
||||
const socket = new MockSocket();
|
||||
let closeCode: number | null = null;
|
||||
let closeReason = "";
|
||||
socket.on("close", (code: unknown, reason: unknown) => {
|
||||
closeCode = typeof code === "number" ? code : null;
|
||||
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
|
||||
});
|
||||
|
||||
await server.attachExternalSocket(socket, { transport: "relay" });
|
||||
socket.emit(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "ping",
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(closeCode).toBe(4002);
|
||||
expect(["Invalid hello", "Session message before hello"]).toContain(closeReason);
|
||||
expect(sessionMock.instances).toHaveLength(0);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reuses direct session when same clientId reconnects within grace window", async () => {
|
||||
const server = createServer();
|
||||
const clientId = "cid-direct-reconnect";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await (server as any).attachSocket(socket1, request);
|
||||
const firstWelcome = await attachDirectAndHello({
|
||||
server,
|
||||
socket: socket1,
|
||||
clientId,
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -365,7 +461,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
const socket2 = new MockSocket();
|
||||
await (server as any).attachSocket(socket2, request);
|
||||
const secondWelcome = await attachDirectAndHello({
|
||||
server,
|
||||
socket: socket2,
|
||||
clientId,
|
||||
});
|
||||
expect(secondWelcome.resumed).toBe(true);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -374,32 +475,25 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reuses one session when switching from direct to relay with the same session key", async () => {
|
||||
test("reuses one session when switching from direct to relay with the same clientId", async () => {
|
||||
const server = createServer();
|
||||
const clientSessionKey = "clsk_switch_path";
|
||||
const directRequest = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: `/ws?clientSessionKey=${clientSessionKey}`,
|
||||
};
|
||||
const relayMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `session:${clientSessionKey}`,
|
||||
};
|
||||
const clientId = "cid-switch-path";
|
||||
|
||||
const directSocket = new MockSocket();
|
||||
await (server as any).attachSocket(directSocket, directRequest);
|
||||
await attachDirectAndHello({
|
||||
server,
|
||||
socket: directSocket,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const relaySocket = new MockSocket();
|
||||
await server.attachExternalSocket(relaySocket, relayMetadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket: relaySocket,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
const onMessage = session.args.onMessage as
|
||||
@@ -411,8 +505,8 @@ describe("relay external socket reconnect behavior", () => {
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(directSocket.sent.length).toBeGreaterThan(1);
|
||||
expect(relaySocket.sent.length).toBeGreaterThan(1);
|
||||
expect(directSocket.sent.length).toBeGreaterThan(0);
|
||||
expect(relaySocket.sent.length).toBeGreaterThan(0);
|
||||
|
||||
directSocket.emit("close", 1006, "");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
@@ -427,13 +521,14 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("cleans up relay session when reconnect grace expires", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-2",
|
||||
};
|
||||
const clientId = "cid-relay-grace-expire";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket: socket1,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -444,53 +539,46 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("includes voice capabilities in server_info when speech readiness exists", async () => {
|
||||
test("includes voice capabilities in welcome when speech readiness exists", async () => {
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
const server = createServer({ speechReadiness });
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-capabilities",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(typeof socket.sent[0]).toBe("string");
|
||||
const envelope = JSON.parse(socket.sent[0] as string) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: unknown;
|
||||
const welcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-capabilities",
|
||||
}) as {
|
||||
version?: unknown;
|
||||
capabilities?: {
|
||||
voice?: {
|
||||
dictation?: { enabled?: unknown; reason?: unknown };
|
||||
voice?: { enabled?: unknown; reason?: unknown };
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(envelope.type).toBe("session");
|
||||
expect(envelope.message?.type).toBe("status");
|
||||
|
||||
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||
expect(payload?.status).toBe("server_info");
|
||||
expect(payload?.version).toBe(TEST_DAEMON_VERSION);
|
||||
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(
|
||||
expect(welcome.version).toBe(TEST_DAEMON_VERSION);
|
||||
expect(welcome.capabilities?.voice?.dictation?.enabled).toBe(
|
||||
speechReadiness.dictation.enabled
|
||||
);
|
||||
expect(payload?.capabilities?.voice?.dictation.reason).toBe("");
|
||||
expect(payload?.capabilities?.voice?.voice.enabled).toBe(
|
||||
expect(welcome.capabilities?.voice?.dictation?.reason).toBe("");
|
||||
expect(welcome.capabilities?.voice?.voice?.enabled).toBe(
|
||||
speechReadiness.realtimeVoice.enabled
|
||||
);
|
||||
expect(payload?.capabilities?.voice?.voice.reason).toBe("");
|
||||
expect(welcome.capabilities?.voice?.voice?.reason).toBe("");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("broadcasts updated server_info when capabilities change", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-broadcast",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-broadcast",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
@@ -513,12 +601,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("includes temporary retry guidance while models are downloading", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-download-guidance",
|
||||
};
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-download-guidance",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
|
||||
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||
@@ -542,13 +630,13 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-binary-inbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-binary-inbound",
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -583,13 +671,13 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("sends outbound binary mux frames from session over websocket", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-binary-outbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-binary-outbound",
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type WSHelloMessage,
|
||||
type WSWelcomeMessage,
|
||||
WSInboundMessageSchema,
|
||||
type ServerCapabilityState,
|
||||
type ServerCapabilities,
|
||||
@@ -51,7 +53,11 @@ import {
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
export type ExternalSocketMetadata = {
|
||||
transport: "relay";
|
||||
externalSessionKey: string;
|
||||
};
|
||||
|
||||
type PendingConnection = {
|
||||
connectionLogger: pino.Logger;
|
||||
helloTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type WebSocketServerConfig = {
|
||||
@@ -152,11 +158,15 @@ type SessionConnection = {
|
||||
clientId: string;
|
||||
connectionLogger: pino.Logger;
|
||||
sockets: Set<WebSocketLike>;
|
||||
externalSessionKey: string | null;
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
|
||||
const HELLO_TIMEOUT_MS = 15_000;
|
||||
const WS_CLOSE_HELLO_TIMEOUT = 4001;
|
||||
const WS_CLOSE_INVALID_HELLO = 4002;
|
||||
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
|
||||
const WS_PROTOCOL_VERSION = 1;
|
||||
|
||||
export class MissingDaemonVersionError extends Error {
|
||||
constructor() {
|
||||
@@ -171,9 +181,9 @@ export class MissingDaemonVersionError extends Error {
|
||||
export class VoiceAssistantWebSocketServer {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly wss: WebSocketServer;
|
||||
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
|
||||
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private clientIdCounter = 0;
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly agentManager: AgentManager;
|
||||
@@ -334,7 +344,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
return;
|
||||
}
|
||||
this.serverCapabilities = next;
|
||||
this.broadcastServerInfo();
|
||||
this.broadcastCapabilitiesUpdate();
|
||||
}
|
||||
|
||||
public async attachExternalSocket(
|
||||
@@ -350,6 +360,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
...this.externalSessionsByKey.values(),
|
||||
]);
|
||||
|
||||
const pendingSockets = new Set<WebSocketLike>(this.pendingConnections.keys());
|
||||
for (const pending of this.pendingConnections.values()) {
|
||||
if (pending.helloTimeout) {
|
||||
clearTimeout(pending.helloTimeout);
|
||||
pending.helloTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
for (const connection of uniqueConnections) {
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
@@ -372,7 +390,22 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ws of pendingSockets) {
|
||||
cleanupPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
if (ws.readyState === 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
ws.close();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
this.pendingConnections.clear();
|
||||
this.sessions.clear();
|
||||
this.externalSessionsByKey.clear();
|
||||
this.wss.close();
|
||||
@@ -416,61 +449,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
metadata?: ExternalSocketMetadata
|
||||
): Promise<void> {
|
||||
const requestMetadata = extractSocketRequestMetadata(request);
|
||||
const relayExternalSessionKey =
|
||||
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
|
||||
? metadata.externalSessionKey
|
||||
: null;
|
||||
const directExternalSessionKey =
|
||||
typeof requestMetadata.clientSessionKey === "string" &&
|
||||
requestMetadata.clientSessionKey.trim().length > 0
|
||||
? `session:${requestMetadata.clientSessionKey.trim()}`
|
||||
: null;
|
||||
const externalSessionKey = relayExternalSessionKey ?? directExternalSessionKey;
|
||||
|
||||
if (metadata?.transport !== "relay" && !directExternalSessionKey) {
|
||||
this.logger.warn(
|
||||
{
|
||||
host: requestMetadata.host,
|
||||
origin: requestMetadata.origin,
|
||||
remoteAddress: requestMetadata.remoteAddress,
|
||||
},
|
||||
"Rejected direct connection without clientSessionKey"
|
||||
);
|
||||
try {
|
||||
ws.close(1008, "Missing clientSessionKey");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (externalSessionKey) {
|
||||
const existing = this.externalSessionsByKey.get(externalSessionKey);
|
||||
if (existing) {
|
||||
if (existing.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(existing.externalDisconnectCleanupTimeout);
|
||||
existing.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
|
||||
existing.sockets.add(ws);
|
||||
this.sessions.set(ws, existing);
|
||||
this.sendServerInfo(ws);
|
||||
existing.connectionLogger.trace(
|
||||
{
|
||||
clientId: existing.clientId,
|
||||
externalSessionKey,
|
||||
totalSessions: this.sessions.size,
|
||||
},
|
||||
"Client reconnected"
|
||||
);
|
||||
this.bindSocketHandlers(ws, existing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = `client-${++this.clientIdCounter}`;
|
||||
const connectionLoggerFields: Record<string, string> = {
|
||||
clientId,
|
||||
transport: metadata?.transport === "relay" ? "relay" : "direct",
|
||||
};
|
||||
if (requestMetadata.host) {
|
||||
@@ -486,6 +465,47 @@ export class VoiceAssistantWebSocketServer {
|
||||
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
|
||||
}
|
||||
const connectionLogger = this.logger.child(connectionLoggerFields);
|
||||
|
||||
const pending: PendingConnection = {
|
||||
connectionLogger,
|
||||
helloTimeout: null,
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingConnections.get(ws) !== pending) {
|
||||
return;
|
||||
}
|
||||
pending.helloTimeout = null;
|
||||
this.pendingConnections.delete(ws);
|
||||
pending.connectionLogger.warn(
|
||||
{ timeoutMs: HELLO_TIMEOUT_MS },
|
||||
"Closing connection due to missing hello"
|
||||
);
|
||||
try {
|
||||
ws.close(WS_CLOSE_HELLO_TIMEOUT, "Hello timeout");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
pending.helloTimeout = timeout;
|
||||
(timeout as unknown as { unref?: () => void }).unref?.();
|
||||
|
||||
this.pendingConnections.set(ws, pending);
|
||||
this.bindSocketHandlers(ws);
|
||||
|
||||
pending.connectionLogger.trace(
|
||||
{
|
||||
totalPendingConnections: this.pendingConnections.size,
|
||||
},
|
||||
"Client connected; awaiting hello"
|
||||
);
|
||||
}
|
||||
|
||||
private createSessionConnection(params: {
|
||||
ws: WebSocketLike;
|
||||
clientId: string;
|
||||
connectionLogger: pino.Logger;
|
||||
}): SessionConnection {
|
||||
const { ws, clientId, connectionLogger } = params;
|
||||
let connection: SessionConnection | null = null;
|
||||
|
||||
const session = new Session({
|
||||
@@ -538,23 +558,109 @@ export class VoiceAssistantWebSocketServer {
|
||||
clientId,
|
||||
connectionLogger,
|
||||
sockets: new Set([ws]),
|
||||
externalSessionKey,
|
||||
externalDisconnectCleanupTimeout: null,
|
||||
};
|
||||
return connection;
|
||||
}
|
||||
|
||||
this.sessions.set(ws, connection);
|
||||
if (externalSessionKey) {
|
||||
this.externalSessionsByKey.set(externalSessionKey, connection);
|
||||
private clearPendingConnection(ws: WebSocketLike): PendingConnection | null {
|
||||
const pending = this.pendingConnections.get(ws);
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
if (pending.helloTimeout) {
|
||||
clearTimeout(pending.helloTimeout);
|
||||
pending.helloTimeout = null;
|
||||
}
|
||||
this.pendingConnections.delete(ws);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private buildWelcomeMessage(params: { resumed: boolean }): WSWelcomeMessage {
|
||||
return {
|
||||
type: "welcome",
|
||||
serverId: this.serverId,
|
||||
hostname: getHostname(),
|
||||
version: this.daemonVersion,
|
||||
resumed: params.resumed,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private handleHello(params: {
|
||||
ws: WebSocketLike;
|
||||
message: WSHelloMessage;
|
||||
pending: PendingConnection;
|
||||
}): void {
|
||||
const { ws, message, pending } = params;
|
||||
|
||||
if (message.protocolVersion !== WS_PROTOCOL_VERSION) {
|
||||
this.clearPendingConnection(ws);
|
||||
pending.connectionLogger.warn(
|
||||
{
|
||||
receivedProtocolVersion: message.protocolVersion,
|
||||
expectedProtocolVersion: WS_PROTOCOL_VERSION,
|
||||
},
|
||||
"Rejected hello due to protocol version mismatch"
|
||||
);
|
||||
try {
|
||||
ws.close(WS_CLOSE_INCOMPATIBLE_PROTOCOL, "Incompatible protocol version");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendServerInfo(ws);
|
||||
const clientId = message.clientId.trim();
|
||||
if (clientId.length === 0) {
|
||||
this.clearPendingConnection(ws);
|
||||
pending.connectionLogger.warn("Rejected hello with empty clientId");
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
connectionLogger.trace(
|
||||
{ clientId, externalSessionKey, totalSessions: this.sessions.size },
|
||||
"Client connected"
|
||||
this.clearPendingConnection(ws);
|
||||
const existing = this.externalSessionsByKey.get(clientId);
|
||||
if (existing) {
|
||||
if (existing.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(existing.externalDisconnectCleanupTimeout);
|
||||
existing.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
existing.sockets.add(ws);
|
||||
this.sessions.set(ws, existing);
|
||||
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: true }));
|
||||
existing.connectionLogger.trace(
|
||||
{
|
||||
clientId,
|
||||
resumed: true,
|
||||
totalSessions: this.sessions.size,
|
||||
},
|
||||
"Client connected via hello"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionLogger = pending.connectionLogger.child({ clientId });
|
||||
const connection = this.createSessionConnection({
|
||||
ws,
|
||||
clientId,
|
||||
connectionLogger,
|
||||
});
|
||||
this.sessions.set(ws, connection);
|
||||
this.externalSessionsByKey.set(clientId, connection);
|
||||
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: false }));
|
||||
connection.connectionLogger.trace(
|
||||
{
|
||||
clientId,
|
||||
resumed: false,
|
||||
totalSessions: this.sessions.size,
|
||||
},
|
||||
"Client connected via hello"
|
||||
);
|
||||
|
||||
this.bindSocketHandlers(ws, connection);
|
||||
}
|
||||
|
||||
private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
|
||||
@@ -567,7 +673,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
};
|
||||
}
|
||||
|
||||
private broadcastServerInfo(): void {
|
||||
private broadcastCapabilitiesUpdate(): void {
|
||||
this.broadcast(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
@@ -576,27 +682,13 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private sendServerInfo(ws: WebSocketLike): void {
|
||||
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
||||
this.sendToClient(
|
||||
ws,
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: this.buildServerInfoStatusPayload(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private bindSocketHandlers(
|
||||
ws: WebSocketLike,
|
||||
connection: SessionConnection
|
||||
): void {
|
||||
private bindSocketHandlers(ws: WebSocketLike): void {
|
||||
ws.on("message", (data) => {
|
||||
void this.handleRawMessage(ws, data);
|
||||
});
|
||||
|
||||
ws.on("close", async (code: number, reason: unknown) => {
|
||||
await this.detachSocket(ws, connection, {
|
||||
await this.detachSocket(ws, {
|
||||
code: typeof code === "number" ? code : undefined,
|
||||
reason,
|
||||
});
|
||||
@@ -604,8 +696,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
ws.on("error", async (error) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
connection.connectionLogger.error({ err }, "Client error");
|
||||
await this.detachSocket(ws, connection, { error: err });
|
||||
const active = this.sessions.get(ws);
|
||||
const pending = this.pendingConnections.get(ws);
|
||||
const log = active?.connectionLogger ?? pending?.connectionLogger ?? this.logger;
|
||||
log.error({ err }, "Client error");
|
||||
await this.detachSocket(ws, { error: err });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -623,19 +718,33 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
private async detachSocket(
|
||||
ws: WebSocketLike,
|
||||
connection: SessionConnection,
|
||||
details: {
|
||||
code?: number;
|
||||
reason?: unknown;
|
||||
error?: Error;
|
||||
}
|
||||
): Promise<void> {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
if (activeConnection !== connection) return;
|
||||
const pending = this.clearPendingConnection(ws);
|
||||
if (pending) {
|
||||
pending.connectionLogger.trace(
|
||||
{
|
||||
code: details.code,
|
||||
reason: stringifyCloseReason(details.reason),
|
||||
},
|
||||
"Pending client disconnected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = this.sessions.get(ws);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessions.delete(ws);
|
||||
connection.sockets.delete(ws);
|
||||
|
||||
if (connection.externalSessionKey && connection.sockets.size === 0) {
|
||||
if (connection.sockets.size === 0) {
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
}
|
||||
@@ -651,7 +760,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.connectionLogger.trace(
|
||||
{
|
||||
clientId: connection.clientId,
|
||||
externalSessionKey: connection.externalSessionKey,
|
||||
code: details.code,
|
||||
reason: stringifyCloseReason(details.reason),
|
||||
reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS,
|
||||
@@ -690,11 +798,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.sessions.delete(socket);
|
||||
}
|
||||
connection.sockets.clear();
|
||||
if (connection.externalSessionKey) {
|
||||
const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
|
||||
if (existing === connection) {
|
||||
this.externalSessionsByKey.delete(connection.externalSessionKey);
|
||||
}
|
||||
const existing = this.externalSessionsByKey.get(connection.clientId);
|
||||
if (existing === connection) {
|
||||
this.externalSessionsByKey.delete(connection.clientId);
|
||||
}
|
||||
|
||||
connection.connectionLogger.trace(
|
||||
@@ -708,15 +814,24 @@ export class VoiceAssistantWebSocketServer {
|
||||
ws: WebSocketLike,
|
||||
data: Buffer | ArrayBuffer | Buffer[] | string
|
||||
): Promise<void> {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
const pendingConnection = this.pendingConnections.get(ws);
|
||||
const log = activeConnection?.connectionLogger ?? pendingConnection?.connectionLogger ?? this.logger;
|
||||
|
||||
try {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
const buffer = bufferFromWsData(data);
|
||||
const asBytes = asUint8Array(buffer);
|
||||
if (asBytes) {
|
||||
const frame = decodeBinaryMuxFrame(asBytes);
|
||||
if (frame) {
|
||||
if (!activeConnection) {
|
||||
this.logger.error("No session found for client");
|
||||
log.warn("Rejected binary frame before hello");
|
||||
this.clearPendingConnection(ws);
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeConnection.session.handleBinaryFrame(frame);
|
||||
@@ -726,6 +841,22 @@ export class VoiceAssistantWebSocketServer {
|
||||
const parsed = JSON.parse(buffer.toString());
|
||||
const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
|
||||
if (!parsedMessage.success) {
|
||||
if (pendingConnection) {
|
||||
pendingConnection.connectionLogger.warn(
|
||||
{
|
||||
error: parsedMessage.error.message,
|
||||
},
|
||||
"Rejected pending message before hello"
|
||||
);
|
||||
this.clearPendingConnection(ws);
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
|
||||
const isUnknownSchema =
|
||||
requestInfo?.requestId != null &&
|
||||
@@ -734,7 +865,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
"type" in parsed &&
|
||||
(parsed as { type?: unknown }).type === "session";
|
||||
|
||||
const log = activeConnection?.connectionLogger ?? this.logger;
|
||||
log.warn(
|
||||
{
|
||||
clientId: activeConnection?.clientId,
|
||||
@@ -786,8 +916,43 @@ export class VoiceAssistantWebSocketServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConnection) {
|
||||
if (message.type === "hello") {
|
||||
this.handleHello({
|
||||
ws,
|
||||
message,
|
||||
pending: pendingConnection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pendingConnection.connectionLogger.warn(
|
||||
{
|
||||
messageType: message.type,
|
||||
},
|
||||
"Rejected pending message before hello"
|
||||
);
|
||||
this.clearPendingConnection(ws);
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeConnection) {
|
||||
this.logger.error("No session found for client");
|
||||
this.logger.error("No connection found for websocket");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "hello") {
|
||||
activeConnection.connectionLogger.warn("Received hello on active connection");
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -816,7 +981,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
||||
: rawPayload;
|
||||
|
||||
this.logger.error(
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
rawPayload: trimmedRawPayload,
|
||||
@@ -825,6 +990,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
"Failed to parse/handle message"
|
||||
);
|
||||
|
||||
if (this.pendingConnections.has(ws)) {
|
||||
this.clearPendingConnection(ws);
|
||||
try {
|
||||
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
|
||||
if (requestInfo) {
|
||||
this.sendToClient(
|
||||
@@ -952,7 +1127,6 @@ type SocketRequestMetadata = {
|
||||
origin?: string;
|
||||
userAgent?: string;
|
||||
remoteAddress?: string;
|
||||
clientSessionKey?: string;
|
||||
};
|
||||
|
||||
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
@@ -983,30 +1157,12 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
typeof record.socket?.remoteAddress === "string"
|
||||
? record.socket.remoteAddress
|
||||
: undefined;
|
||||
const rawUrl = typeof record.url === "string" ? record.url : null;
|
||||
const clientSessionKey = (() => {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl, "http://localhost");
|
||||
const value = parsed.searchParams.get("clientSessionKey");
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...(host ? { host } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(remoteAddress ? { remoteAddress } : {}),
|
||||
...(clientSessionKey ? { clientSessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,24 @@ describe("relay websocket URL versioning", () => {
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
clientSessionKey: "clsk_test",
|
||||
})
|
||||
);
|
||||
|
||||
expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION);
|
||||
expect(url.searchParams.get("clientId")).toBe("clsk_test");
|
||||
expect(url.searchParams.has("connectionId")).toBe(false);
|
||||
});
|
||||
|
||||
test("includes connectionId when provided (server data sockets)", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
role: "server",
|
||||
connectionId: "conn_abc123",
|
||||
})
|
||||
);
|
||||
|
||||
expect(url.searchParams.get("connectionId")).toBe("conn_abc123");
|
||||
});
|
||||
|
||||
test("allows explicitly requesting v1 relay URLs", () => {
|
||||
|
||||
@@ -83,28 +83,23 @@ function shouldUseSecureWebSocket(port: number): boolean {
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(
|
||||
endpoint: string,
|
||||
params?: { clientSessionKey?: string }
|
||||
endpoint: string
|
||||
): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
if (
|
||||
typeof params?.clientSessionKey === "string" &&
|
||||
params.clientSessionKey.trim().length > 0
|
||||
) {
|
||||
url.searchParams.set("clientSessionKey", params.clientSessionKey.trim());
|
||||
}
|
||||
return url.toString();
|
||||
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
role: RelayRole;
|
||||
clientId?: string;
|
||||
clientSessionKey?: string;
|
||||
/**
|
||||
* Per-connection routing identifier used by the daemon to open server data sockets.
|
||||
* Clients should NOT provide this — the relay assigns a routing ID on connect.
|
||||
*/
|
||||
connectionId?: string;
|
||||
version?: RelayProtocolVersion | 1 | 2;
|
||||
}): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
@@ -114,16 +109,8 @@ export function buildRelayWebSocketUrl(params: {
|
||||
url.searchParams.set("serverId", params.serverId);
|
||||
url.searchParams.set("role", params.role);
|
||||
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
|
||||
if (
|
||||
params.clientId &&
|
||||
params.clientSessionKey &&
|
||||
params.clientId !== params.clientSessionKey
|
||||
) {
|
||||
throw new Error("clientId and clientSessionKey must match when both are provided");
|
||||
}
|
||||
const resolvedClientId = params.clientId ?? params.clientSessionKey;
|
||||
if (resolvedClientId) {
|
||||
url.searchParams.set("clientId", resolvedClientId);
|
||||
if (params.connectionId) {
|
||||
url.searchParams.set("connectionId", params.connectionId);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -78,6 +78,48 @@ describe('shared messages stream parsing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('parses representative sub_agent tool_call event', () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: 'agent_stream',
|
||||
payload: {
|
||||
agentId: 'agent_live',
|
||||
timestamp: '2026-02-08T20:10:00.000Z',
|
||||
event: {
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: 'tool_call',
|
||||
callId: 'call_sub_agent_live',
|
||||
name: 'Task',
|
||||
status: 'running',
|
||||
detail: {
|
||||
type: 'sub_agent',
|
||||
subAgentType: 'Explore',
|
||||
description: 'Inspect repository structure',
|
||||
log: '[Read] README.md',
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: 'Read',
|
||||
summary: 'README.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(parsed.payload.event.type).toBe('timeline')
|
||||
if (parsed.payload.event.type === 'timeline') {
|
||||
expect(parsed.payload.event.item.type).toBe('tool_call')
|
||||
if (parsed.payload.event.item.type === 'tool_call') {
|
||||
expect(parsed.payload.event.item.detail.type).toBe('sub_agent')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects removed initialize_agent_request inbound payload', () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: 'initialize_agent_request',
|
||||
|
||||
@@ -109,4 +109,41 @@ describe("shared messages tool_call schema", () => {
|
||||
expect(missingDetail.success).toBe(false);
|
||||
expect(legacyStatus.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses canonical sub_agent detail payload", () => {
|
||||
const parsed = AgentTimelineItemPayloadSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call_sub_agent_1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Bash",
|
||||
summary: "ls",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("tool_call");
|
||||
if (parsed.type === "tool_call") {
|
||||
expect(parsed.detail.type).toBe("sub_agent");
|
||||
if (parsed.detail.type === "sub_agent") {
|
||||
expect(parsed.detail.subAgentType).toBe("Explore");
|
||||
expect(parsed.detail.actions).toHaveLength(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,24 +38,29 @@ describe("shared tool-call display mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps task metadata summary on unknown detail", () => {
|
||||
it("uses sub-agent detail for task label and description", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
displayName: "Explore",
|
||||
summary: "Inspect repository structure",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
||||
displayName = "Worktree Setup";
|
||||
summary = input.detail.branchName;
|
||||
break;
|
||||
case "sub_agent":
|
||||
displayName = readString(input.detail.subAgentType) ?? "Task";
|
||||
summary = readString(input.detail.description);
|
||||
break;
|
||||
case "unknown":
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user