mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
@@ -447,6 +447,26 @@ describe('ApiSessionClient agy transcript messages', () => {
|
||||
})
|
||||
|
||||
describe('ApiSessionClient incoming user messages', () => {
|
||||
it.each([true, false])('replays explicitly marked native queue input only for shared sessions (%s)', shared => {
|
||||
socketHarness.sockets.length = 0
|
||||
const client = new ApiSessionClient('token', createSession({
|
||||
metadata: { path: '/tmp', host: 'test', capabilities: { concurrentClients: shared } }
|
||||
}))
|
||||
const socket = socketHarness.sockets[0]
|
||||
const received = vi.fn()
|
||||
client.onUserMessage(received)
|
||||
const message = { id: 'native-queued', seq: 10, localId: 'native-client-id', content: {
|
||||
role: 'user', content: { type: 'text', text: 'queued before exit' },
|
||||
meta: { sentFrom: 'cli', isNativeQueuedMessage: true }
|
||||
} }
|
||||
socket.trigger('update', { body: { t: 'new-message', message } })
|
||||
socket.trigger('update', { body: { t: 'new-message', message } })
|
||||
expect(received).toHaveBeenCalledTimes(shared ? 1 : 0)
|
||||
if (shared) expect(received).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: { type: 'text', text: 'queued before exit' }
|
||||
}), 'native-client-id')
|
||||
client.close()
|
||||
})
|
||||
it('ignores CLI-originated transcript messages while advancing the incoming cursor', () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
|
||||
|
||||
+40
-17
@@ -1,3 +1,4 @@
|
||||
type QueueCancelResult = boolean | 'in-flight' | 'indeterminate' | 'consumed'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
@@ -36,7 +37,7 @@ import type {
|
||||
import { AgentStateSchema, CliMessagesResponseSchema, MetadataSchema, UserMessageSchema } from './types'
|
||||
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
||||
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
||||
import { cleanupUploadDir } from '../modules/common/handlers/uploads'
|
||||
import { cleanupUploadDir, preserveUploadDirOnExit } from '../modules/common/handlers/uploads'
|
||||
import { TerminalManager } from '@/terminal/TerminalManager'
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
@@ -231,6 +232,8 @@ function hasSameJsonValue(left: unknown, right: unknown): boolean {
|
||||
}
|
||||
|
||||
export class ApiSessionClient extends EventEmitter {
|
||||
private reconnectHandler: (() => void) | null = null
|
||||
onReconnect(handler: (() => void) | null): void { this.reconnectHandler = handler }
|
||||
private readonly token: string
|
||||
readonly sessionId: string
|
||||
private metadata: Metadata | null
|
||||
@@ -241,8 +244,8 @@ export class ApiSessionClient extends EventEmitter {
|
||||
private pendingMessages: { message: UserMessage; localId?: string }[] = []
|
||||
private pendingHubPromptEchoes: { text: string; localIds: string[] }[] = []
|
||||
private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null
|
||||
private cancelQueuedMessageCallback: ((localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed') | null = null
|
||||
private retryQueuedMessageCallback: ((localId: string) => boolean) | null = null
|
||||
private cancelQueuedMessageCallback: ((localId: string) => QueueCancelResult | Promise<QueueCancelResult>) | null = null
|
||||
private retryQueuedMessageCallback: ((localId: string) => boolean | Promise<boolean>) | null = null
|
||||
private readonly incomingFilter = new IncomingMessageFilter()
|
||||
private backfillInFlight: Promise<void> | null = null
|
||||
private needsBackfill = false
|
||||
@@ -334,6 +337,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}
|
||||
void this.backfillIfNeeded()
|
||||
this.hasConnectedOnce = true
|
||||
this.reconnectHandler?.()
|
||||
this.socket.emit('session-alive', {
|
||||
sid: this.sessionId,
|
||||
time: Date.now(),
|
||||
@@ -422,7 +426,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
this.agentTerminalActive = false
|
||||
}))
|
||||
|
||||
this.socket.on('update', (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => {
|
||||
this.socket.on('update', async (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => {
|
||||
try {
|
||||
if (!data.body) return
|
||||
|
||||
@@ -436,10 +440,11 @@ export class ApiSessionClient extends EventEmitter {
|
||||
// reservation and bypass the normal message-id dedup.
|
||||
let accepted = true
|
||||
if (data.body.localId && this.cancelQueuedMessageCallback) {
|
||||
const cancelled = this.cancelQueuedMessageCallback(data.body.localId)
|
||||
const cancellation = this.cancelQueuedMessageCallback(data.body.localId)
|
||||
const cancelled = cancellation instanceof Promise ? await cancellation : cancellation
|
||||
accepted = cancelled !== 'in-flight' && cancelled !== 'consumed'
|
||||
if (cancelled === 'indeterminate') {
|
||||
accepted = this.retryQueuedMessageCallback?.(data.body.localId) === true
|
||||
accepted = await this.retryQueuedMessageCallback?.(data.body.localId) === true
|
||||
}
|
||||
if (cancelled === 'consumed') {
|
||||
ack?.({ removed: false, accepted: false, consumed: true })
|
||||
@@ -454,9 +459,10 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}
|
||||
|
||||
if (data.body.t === 'cancel-queued-message') {
|
||||
const result = (data.body.localId && this.cancelQueuedMessageCallback)
|
||||
const cancellation = (data.body.localId && this.cancelQueuedMessageCallback)
|
||||
? this.cancelQueuedMessageCallback(data.body.localId)
|
||||
: false
|
||||
const result = cancellation instanceof Promise ? await cancellation : cancellation
|
||||
// 'in-flight' = the row is inside an async steer: not
|
||||
// removed, but also NOT consumed — the hub must neither
|
||||
// delete it nor stamp invoked_at.
|
||||
@@ -499,6 +505,11 @@ export class ApiSessionClient extends EventEmitter {
|
||||
this.emit('message', data.body)
|
||||
} catch (error) {
|
||||
logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error })
|
||||
// A failed asynchronous native cancellation is not permission
|
||||
// for the hub's best-effort path to delete a possibly live row.
|
||||
if (data.body?.t === 'cancel-queued-message' || data.body?.t === 'retry-queued-message') {
|
||||
ack?.({ removed: false, accepted: false, indeterminate: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -696,11 +707,11 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
onCancelQueuedMessage(callback: (localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed'): void {
|
||||
onCancelQueuedMessage(callback: (localId: string) => QueueCancelResult | Promise<QueueCancelResult>): void {
|
||||
this.cancelQueuedMessageCallback = callback
|
||||
}
|
||||
|
||||
onRetryQueuedMessage(callback: (localId: string) => boolean): void {
|
||||
onRetryQueuedMessage(callback: (localId: string) => boolean | Promise<boolean>): void {
|
||||
this.retryQueuedMessageCallback = callback
|
||||
}
|
||||
|
||||
@@ -782,7 +793,10 @@ export class ApiSessionClient extends EventEmitter {
|
||||
// User messages mirrored from a local agent transcript are history,
|
||||
// not new remote input. Keep them in the incoming filter above so
|
||||
// reconnect backfill still advances and deduplicates correctly.
|
||||
if (userResult.data.meta?.sentFrom === 'cli') {
|
||||
if (userResult.data.meta?.sentFrom === 'cli'
|
||||
&& !(userResult.data.meta.isNativeQueuedMessage === true
|
||||
&& this.metadata?.capabilities?.concurrentClients === true
|
||||
&& message.localId)) {
|
||||
return
|
||||
}
|
||||
this.enqueueUserMessage(userResult.data, message.localId ?? undefined)
|
||||
@@ -1013,7 +1027,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
sendUserMessage(text: string, meta?: MessageMeta): void {
|
||||
sendUserMessage(text: string, meta?: MessageMeta, localId?: string): void {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
@@ -1033,17 +1047,23 @@ export class ApiSessionClient extends EventEmitter {
|
||||
this.emitOrQueue(() => {
|
||||
this.socket.emit('message', {
|
||||
sid: this.sessionId,
|
||||
message: content
|
||||
message: content,
|
||||
localId
|
||||
})
|
||||
if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] })
|
||||
})
|
||||
this.notifyUserActivity()
|
||||
}
|
||||
|
||||
syncNativeQueuedMessage(localId: string, text: string | null): void {
|
||||
this.emitOrQueue(() => this.socket.emit('native-queue-message', { sid: this.sessionId, localId, text }))
|
||||
}
|
||||
|
||||
notifyUserActivity(): void {
|
||||
void this.materialize()
|
||||
}
|
||||
|
||||
sendAgentMessage(body: unknown): void {
|
||||
sendAgentMessage(body: unknown, localId?: string): void {
|
||||
const content = {
|
||||
role: 'agent',
|
||||
content: {
|
||||
@@ -1057,8 +1077,10 @@ export class ApiSessionClient extends EventEmitter {
|
||||
this.emitOrQueue(() => {
|
||||
this.socket.emit('message', {
|
||||
sid: this.sessionId,
|
||||
message: content
|
||||
message: content,
|
||||
localId
|
||||
})
|
||||
if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1180,7 +1202,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
|
||||
keepAlive(
|
||||
thinking: boolean,
|
||||
mode: 'local' | 'remote',
|
||||
mode: 'local' | 'remote' | undefined,
|
||||
runtime?: {
|
||||
permissionMode?: SessionPermissionMode
|
||||
model?: SessionModel
|
||||
@@ -1264,9 +1286,10 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}))
|
||||
}
|
||||
|
||||
sendSessionDeath(reason?: SessionEndReason): void {
|
||||
sendSessionDeath(reason?: SessionEndReason, options?: { preserveUploads?: boolean }): void {
|
||||
if (this.state === 'active') {
|
||||
void cleanupUploadDir(this.sessionId)
|
||||
if (options?.preserveUploads) preserveUploadDirOnExit(this.sessionId)
|
||||
else void cleanupUploadDir(this.sessionId)
|
||||
}
|
||||
this.emitOrQueue(() => {
|
||||
this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason })
|
||||
|
||||
@@ -71,6 +71,8 @@ export type {
|
||||
|
||||
export const MessageMetaSchema = z.object({
|
||||
sentFrom: z.string().optional(),
|
||||
// Shared Codex queue mirrors are replayable input, not transcript echoes.
|
||||
isNativeQueuedMessage: z.boolean().optional(),
|
||||
// Claude jsonl echoes the remote (web/telegram) prompt as a second user row.
|
||||
// Hub notify ingest skips these so they do not consume a work_ad cause slot.
|
||||
isTranscriptEcho: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user