feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送
- sessionState.ts: 单 listener → Set 多订阅者,新增 registerSdkEventConsumer 绕过 SDK 事件队列 - sdkEventQueue.ts: SessionStateChangedEvent 从队列系统移除 - print.ts: runHeadlessStreaming 注册直接消费者,事件即时 output.enqueue() - query.ts: queryLoop 每轮迭代发送 running 心跳 - server/routes/session.ts: prompt_async 立即 emit busy(不等子进程 init),历史会话先解析 body 再恢复 - server/sessionHandle.ts: prompt() 检测已 busy 时跳过重复 emit - server/eventBus.ts: 添加 SSE publish 耗时日志 - server/server.ts: 传递 eventBus 给 sessionRoutes - 新增 16 个 sessionState 多订阅者 + SDK consumer 测试
This commit is contained in:
parent
5890d16b3a
commit
1a440af30b
|
|
@ -54,6 +54,7 @@ import {
|
||||||
getSessionState,
|
getSessionState,
|
||||||
notifySessionStateChanged,
|
notifySessionStateChanged,
|
||||||
notifySessionMetadataChanged,
|
notifySessionMetadataChanged,
|
||||||
|
registerSdkEventConsumer,
|
||||||
setPermissionModeChangedListener,
|
setPermissionModeChangedListener,
|
||||||
type RequiresActionDetails,
|
type RequiresActionDetails,
|
||||||
type SessionExternalMetadata,
|
type SessionExternalMetadata,
|
||||||
|
|
@ -1029,6 +1030,13 @@ function runHeadlessStreaming(
|
||||||
// Same queue sendRequest() enqueues to — one FIFO for everything.
|
// Same queue sendRequest() enqueues to — one FIFO for everything.
|
||||||
const output = structuredIO.outbound
|
const output = structuredIO.outbound
|
||||||
|
|
||||||
|
// Push session_state_changed events directly to output (bypasses SDK
|
||||||
|
// event queue). Aligned with OpenCode: status transitions reach clients
|
||||||
|
// immediately via direct push, not delayed until drainSdkEvents().
|
||||||
|
const unregisterSdkConsumer = registerSdkEventConsumer(event => {
|
||||||
|
output.enqueue(event as unknown as StdoutMessage)
|
||||||
|
})
|
||||||
|
|
||||||
// Ctrl+C in -p mode: abort the in-flight query, then shut down gracefully.
|
// Ctrl+C in -p mode: abort the in-flight query, then shut down gracefully.
|
||||||
// gracefulShutdown persists session state and flushes analytics, with a
|
// gracefulShutdown persists session state and flushes analytics, with a
|
||||||
// failsafe timer that force-exits if cleanup hangs.
|
// failsafe timer that force-exits if cleanup hangs.
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ import {
|
||||||
} from './utils/messageQueueManager.js'
|
} from './utils/messageQueueManager.js'
|
||||||
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
|
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
|
||||||
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
|
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
|
||||||
|
import { notifySessionStateChanged } from './utils/sessionState.js'
|
||||||
import {
|
import {
|
||||||
getRuntimeMainLoopModel,
|
getRuntimeMainLoopModel,
|
||||||
renderModelName,
|
renderModelName,
|
||||||
|
|
@ -377,6 +378,11 @@ async function* queryLoop(
|
||||||
toolUseContext,
|
toolUseContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Emit heartbeat at each iteration start so SDK consumers see
|
||||||
|
// the session is still active during long tool execution pauses.
|
||||||
|
// Aligned with OpenCode: each AI loop iteration sets 'busy'.
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
|
||||||
yield { type: 'stream_request_start' }
|
yield { type: 'stream_request_start' }
|
||||||
|
|
||||||
queryCheckpoint('query_fn_entry')
|
queryCheckpoint('query_fn_entry')
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,6 @@ export class EventBus {
|
||||||
data: JSON.stringify({ type: 'server.connected' }),
|
data: JSON.stringify({ type: 'server.connected' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const buffered of this.buffer) {
|
|
||||||
void sendAndCleanup({
|
|
||||||
event: buffered.event,
|
|
||||||
data: JSON.stringify(buffered.data),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +53,7 @@ export class EventBus {
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(event: string, data: unknown): void {
|
publish(event: string, data: unknown): void {
|
||||||
|
const tPublish = event === 'session.status' ? Date.now() : 0
|
||||||
const entry: BusEvent = { event, data }
|
const entry: BusEvent = { event, data }
|
||||||
this.buffer.push(entry)
|
this.buffer.push(entry)
|
||||||
if (this.buffer.length > this.bufferSize) {
|
if (this.buffer.length > this.bufferSize) {
|
||||||
|
|
@ -67,6 +61,7 @@ export class EventBus {
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify(data)
|
const payload = JSON.stringify(data)
|
||||||
const deadIds: string[] = []
|
const deadIds: string[] = []
|
||||||
|
let clientCount = 0
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
if (client.writer) {
|
if (client.writer) {
|
||||||
if (client.sessionIdFilter) {
|
if (client.sessionIdFilter) {
|
||||||
|
|
@ -85,6 +80,7 @@ export class EventBus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
clientCount++
|
||||||
client.writer.writeSSE({ event, data: payload }).catch(() => {
|
client.writer.writeSSE({ event, data: payload }).catch(() => {
|
||||||
deadIds.push(client.id)
|
deadIds.push(client.id)
|
||||||
})
|
})
|
||||||
|
|
@ -95,6 +91,13 @@ export class EventBus {
|
||||||
this.clients.delete(id)
|
this.clients.delete(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event === 'session.status' && tPublish > 0) {
|
||||||
|
const dataObj = data as Record<string, unknown>
|
||||||
|
const sessionId = dataObj?.sessionID ?? dataObj?.session_id
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${sessionId}] eventBus.publish('session.status') → ${clientCount} SSE clients, +${Date.now() - tPublish}ms in publish loop\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
publishSessionEvent(
|
publishSessionEvent(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { streamSSE } from 'hono/streaming'
|
import { streamSSE } from 'hono/streaming'
|
||||||
import type { SessionManager } from '../sessionManager.js'
|
import type { SessionManager } from '../sessionManager.js'
|
||||||
|
import type { EventBus } from '../eventBus.js'
|
||||||
import { getScriptArgsForChild } from '../childSpawn.js'
|
import { getScriptArgsForChild } from '../childSpawn.js'
|
||||||
import {
|
import {
|
||||||
badRequest,
|
badRequest,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
conflict,
|
conflict,
|
||||||
} from '../errors.js'
|
} from '../errors.js'
|
||||||
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
|
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
|
||||||
|
import { canonicalizePath } from '../../utils/sessionStoragePortable.js'
|
||||||
|
|
||||||
/** 从磁盘历史记录查找 session 的原始 cwd,用于 resume 时恢复正确的工作目录 */
|
/** 从磁盘历史记录查找 session 的原始 cwd,用于 resume 时恢复正确的工作目录 */
|
||||||
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
|
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
|
||||||
|
|
@ -120,6 +122,7 @@ function ssePrompt(
|
||||||
|
|
||||||
export function createSessionRoutes(
|
export function createSessionRoutes(
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
eventBus: EventBus,
|
||||||
): Hono {
|
): Hono {
|
||||||
return new Hono()
|
return new Hono()
|
||||||
.post('/session', async c => {
|
.post('/session', async c => {
|
||||||
|
|
@ -239,8 +242,13 @@ export function createSessionRoutes(
|
||||||
|
|
||||||
// 补充内存中有但磁盘还没落盘的活跃 session(刚创建还没写过消息的)
|
// 补充内存中有但磁盘还没落盘的活跃 session(刚创建还没写过消息的)
|
||||||
const historyIds = new Set(historySessions.map(s => s.sessionId))
|
const historyIds = new Set(historySessions.map(s => s.sessionId))
|
||||||
|
const canonicalDir = dir ? await canonicalizePath(dir).catch(() => dir) : undefined
|
||||||
for (const handle of sessionManager.getAllSessions()) {
|
for (const handle of sessionManager.getAllSessions()) {
|
||||||
if (!historyIds.has(handle.sessionId)) {
|
if (!historyIds.has(handle.sessionId)) {
|
||||||
|
if (canonicalDir) {
|
||||||
|
const handleCwd = await canonicalizePath(handle.cwd).catch(() => handle.cwd)
|
||||||
|
if (handleCwd !== canonicalDir) continue
|
||||||
|
}
|
||||||
const info = handle.getInfo()
|
const info = handle.getInfo()
|
||||||
merged.push({ ...info, title: info.title ?? '' })
|
merged.push({ ...info, title: info.title ?? '' })
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +354,12 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.delete('/session/:sessionID', async c => {
|
.delete('/session/:sessionID', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
|
<<<<<<< HEAD
|
||||||
await sessionManager.deleteSession(id)
|
await sessionManager.deleteSession(id)
|
||||||
|
=======
|
||||||
|
const deleted = sessionManager.deleteSession(id)
|
||||||
|
if (!deleted) throw notFound('session not found')
|
||||||
|
>>>>>>> dab72c6b (feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送)
|
||||||
return c.json({ deleted: true })
|
return c.json({ deleted: true })
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/prompt', async c => {
|
.post('/session/:sessionID/prompt', async c => {
|
||||||
|
|
@ -401,27 +414,12 @@ export function createSessionRoutes(
|
||||||
return ssePrompt(handle, id, content, c, body.parts, body.messageID)
|
return ssePrompt(handle, id, content, c, body.parts, body.messageID)
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/prompt_async', async c => {
|
.post('/session/:sessionID/prompt_async', async c => {
|
||||||
|
const tEntry = Date.now()
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
|
|
||||||
let handle = sessionManager.getSession(id)
|
// Parse body FIRST — independent of session lookup, so we can
|
||||||
|
// emit busy status immediately even for historical sessions that
|
||||||
// 历史 session 不在内存中,自动恢复
|
// need to create+waitReady (3–30s).
|
||||||
if (!handle) {
|
|
||||||
const cwd = await getHistoryCwd(id)
|
|
||||||
try {
|
|
||||||
handle = await sessionManager.createSession({
|
|
||||||
cwd,
|
|
||||||
sessionId: id,
|
|
||||||
resumeSessionId: id,
|
|
||||||
execPath: process.execPath,
|
|
||||||
scriptArgs: getScriptArgsForChild(),
|
|
||||||
})
|
|
||||||
await handle.waitReady(30000)
|
|
||||||
} catch (err) {
|
|
||||||
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
content?: string
|
content?: string
|
||||||
parts?: Array<Record<string, unknown>>
|
parts?: Array<Record<string, unknown>>
|
||||||
|
|
@ -444,22 +442,93 @@ export function createSessionRoutes(
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw badRequest('content is required')
|
throw badRequest('content is required')
|
||||||
}
|
}
|
||||||
if (handle.prompting) {
|
|
||||||
|
// Emit busy IMMEDIATELY — before session recovery or child-process
|
||||||
|
// init. Aligned with OpenCode: status.set fires in the request
|
||||||
|
// handler synchronously, not after waiting for the child process.
|
||||||
|
eventBus.publish('session.status', {
|
||||||
|
sessionID: id,
|
||||||
|
status: { type: 'busy' },
|
||||||
|
})
|
||||||
|
const tBusyEmitted = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] busy emitted at +${tBusyEmitted - tEntry}ms (immediate)\n`,
|
||||||
|
)
|
||||||
|
|
||||||
|
let handle = sessionManager.getSession(id)
|
||||||
|
|
||||||
|
// 历史 session 不在内存中,自动恢复
|
||||||
|
if (!handle) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] historical session, starting recovery...\n`,
|
||||||
|
)
|
||||||
|
const cwd = await getHistoryCwd(id)
|
||||||
|
try {
|
||||||
|
handle = await sessionManager.createSession({
|
||||||
|
cwd,
|
||||||
|
sessionId: id,
|
||||||
|
resumeSessionId: id,
|
||||||
|
execPath: process.execPath,
|
||||||
|
scriptArgs: getScriptArgsForChild(),
|
||||||
|
})
|
||||||
|
const tCreated = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] session created at +${tCreated - tEntry}ms, waiting for ready...\n`,
|
||||||
|
)
|
||||||
|
await handle.waitReady(30000)
|
||||||
|
const tReady = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] session ready at +${tReady - tEntry}ms\n`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
// Recovery failed — clear the busy status we emitted earlier
|
||||||
|
eventBus.publish('session.status', {
|
||||||
|
sessionID: id,
|
||||||
|
status: { type: 'idle' },
|
||||||
|
})
|
||||||
|
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
|
||||||
|
}
|
||||||
|
} else if (handle.prompting) {
|
||||||
throw conflict('session is already processing a prompt')
|
throw conflict('session is already processing a prompt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tQueued = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] session ready, queuing prompt at +${tQueued - tEntry}ms\n`,
|
||||||
|
)
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
const tFire = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] IIFE starts, status=${handle.status} at +${tFire - tEntry}ms\n`,
|
||||||
|
)
|
||||||
if (handle.status !== 'running') {
|
if (handle.status !== 'running') {
|
||||||
await handle.waitReady()
|
await handle.waitReady()
|
||||||
|
const tReady = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] IIFE waitReady done at +${tReady - tEntry}ms\n`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (body.model?.modelID) {
|
if (body.model?.modelID) {
|
||||||
|
const tModelStart = Date.now()
|
||||||
try { await handle.setModel(body.model.modelID) } catch {}
|
try { await handle.setModel(body.model.modelID) } catch {}
|
||||||
|
const tModelDone = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] setModel done at +${tModelDone - tEntry}ms (took ${tModelDone - tModelStart}ms)\n`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
<<<<<<< HEAD
|
||||||
const effectiveAgent = body.agent ?? (agentParts[0] as Record<string, unknown> | undefined)?.name as string | undefined
|
const effectiveAgent = body.agent ?? (agentParts[0] as Record<string, unknown> | undefined)?.name as string | undefined
|
||||||
if (effectiveAgent && effectiveAgent !== handle.agent) {
|
if (effectiveAgent && effectiveAgent !== handle.agent) {
|
||||||
try { await handle.setAgent(effectiveAgent) } catch {}
|
try { await handle.setAgent(effectiveAgent) } catch {}
|
||||||
}
|
}
|
||||||
|
=======
|
||||||
|
const tPromptCall = Date.now()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${id}] calling prompt() at +${tPromptCall - tEntry}ms\n`,
|
||||||
|
)
|
||||||
|
>>>>>>> dab72c6b (feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送)
|
||||||
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
|
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
|
||||||
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function startServer(
|
||||||
|
|
||||||
app.route('/', createHealthRoutes(sessionManager))
|
app.route('/', createHealthRoutes(sessionManager))
|
||||||
app.route('/', createInfoRoutes(sessionManager))
|
app.route('/', createInfoRoutes(sessionManager))
|
||||||
app.route('/', createSessionRoutes(sessionManager))
|
app.route('/', createSessionRoutes(sessionManager, eventBus))
|
||||||
app.route('/', createEventRoutes(eventBus))
|
app.route('/', createEventRoutes(eventBus))
|
||||||
app.route('/', createPermissionRoutes(sessionManager))
|
app.route('/', createPermissionRoutes(sessionManager))
|
||||||
app.route('/', createQuestionRoutes(sessionManager))
|
app.route('/', createQuestionRoutes(sessionManager))
|
||||||
|
|
|
||||||
|
|
@ -485,13 +485,18 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(content: string, opts?: { parts?: Array<Record<string, unknown>>; messageID?: string }): Promise<{ done: boolean; error?: string }> {
|
async prompt(content: string, opts?: { parts?: Array<Record<string, unknown>>; messageID?: string }): Promise<{ done: boolean; error?: string }> {
|
||||||
|
const tEnter = Date.now()
|
||||||
if (this._status === 'stopped') {
|
if (this._status === 'stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is stopped`,
|
`Session ${this.sessionId} is stopped`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (this._status !== 'running') {
|
if (this._status !== 'running') {
|
||||||
|
const tWaitStart = Date.now()
|
||||||
await this.waitReady()
|
await this.waitReady()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${this.sessionId}] prompt() waitReady took ${Date.now() - tWaitStart}ms\n`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (this._prompting) {
|
if (this._prompting) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -499,8 +504,18 @@ export class SessionHandle {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this._prompting = true
|
this._prompting = true
|
||||||
this._busyStatus = { type: 'busy' }
|
|
||||||
this.emitBusyStatus()
|
// Busy status was already emitted from the route handler (immediate).
|
||||||
|
// Only emit here if this prompt() was called directly (not via prompt_async).
|
||||||
|
const alreadyBusy = this._busyStatus?.type === 'busy'
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${this.sessionId}] prompt() entry, alreadyBusy=${alreadyBusy}, +0ms from prompt() call\n`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyBusy) {
|
||||||
|
this._busyStatus = { type: 'busy' }
|
||||||
|
this.emitBusyStatus()
|
||||||
|
}
|
||||||
this.lastActiveAt = Date.now()
|
this.lastActiveAt = Date.now()
|
||||||
|
|
||||||
const uuid = opts?.messageID ?? crypto.randomUUID()
|
const uuid = opts?.messageID ?? crypto.randomUUID()
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,9 @@ function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
||||||
ctx.setBusyStatus({ type: 'idle' })
|
ctx.setBusyStatus({ type: 'idle' })
|
||||||
ctx.emitBusyStatus()
|
ctx.emitBusyStatus()
|
||||||
|
process.stderr.write(
|
||||||
|
`[serve:timing:${ctx.sessionId}] result → idle emitted\n`,
|
||||||
|
)
|
||||||
ctx.emitEvent('result', msg)
|
ctx.emitEvent('result', msg)
|
||||||
ctx.resolvePrompt({ done: true })
|
ctx.resolvePrompt({ done: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
notifyAutomationStateChanged,
|
notifyAutomationStateChanged,
|
||||||
notifySessionStateChanged,
|
notifyPermissionModeChanged,
|
||||||
notifySessionMetadataChanged,
|
notifySessionMetadataChanged,
|
||||||
|
notifySessionStateChanged,
|
||||||
|
registerSdkEventConsumer,
|
||||||
resetSessionStateForTests,
|
resetSessionStateForTests,
|
||||||
|
setPermissionModeChangedListener,
|
||||||
setSessionMetadataChangedListener,
|
setSessionMetadataChangedListener,
|
||||||
|
setSessionStateChangedListener,
|
||||||
} from '../sessionState'
|
} from '../sessionState'
|
||||||
|
|
||||||
describe('sessionState metadata replay', () => {
|
describe('sessionState metadata replay', () => {
|
||||||
|
|
@ -172,3 +176,275 @@ describe('sessionState metadata replay', () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('sessionState multi-subscriber', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSessionStateForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setSessionStateChangedListener returns unsubscribe function', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const unsub = setSessionStateChangedListener(() => calls.push('L1'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
expect(calls).toEqual(['L1'])
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
notifySessionStateChanged('idle')
|
||||||
|
expect(calls).toEqual(['L1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple state listeners all receive notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
setSessionStateChangedListener(() => calls.push('L1'))
|
||||||
|
setSessionStateChangedListener(() => calls.push('L2'))
|
||||||
|
setSessionStateChangedListener(() => calls.push('L3'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
|
||||||
|
expect(calls).toEqual(['L1', 'L2', 'L3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsubscribed state listener does not receive subsequent notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const unsub1 = setSessionStateChangedListener(() => calls.push('L1'))
|
||||||
|
setSessionStateChangedListener(() => calls.push('L2'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
expect(calls).toEqual(['L1', 'L2'])
|
||||||
|
|
||||||
|
unsub1()
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
notifySessionStateChanged('requires_action')
|
||||||
|
expect(calls).toEqual(['L2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('state listeners receive state and details', () => {
|
||||||
|
const received: Array<{ state: string; details?: Record<string, unknown> }> = []
|
||||||
|
setSessionStateChangedListener((state, details) => {
|
||||||
|
received.push({ state, details: details as Record<string, unknown> | undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
notifySessionStateChanged('requires_action', {
|
||||||
|
tool_name: 'Bash',
|
||||||
|
action_description: 'Running tests',
|
||||||
|
tool_use_id: 'toolu_001',
|
||||||
|
request_id: 'req_001',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(received).toEqual([
|
||||||
|
{
|
||||||
|
state: 'requires_action',
|
||||||
|
details: {
|
||||||
|
tool_name: 'Bash',
|
||||||
|
action_description: 'Running tests',
|
||||||
|
tool_use_id: 'toolu_001',
|
||||||
|
request_id: 'req_001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple metadata listeners all receive notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
setSessionMetadataChangedListener(m => {
|
||||||
|
calls.push(`L1:${m.model ?? 'no-model'}`)
|
||||||
|
})
|
||||||
|
setSessionMetadataChangedListener(m => {
|
||||||
|
calls.push(`L2:${m.model ?? 'no-model'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
notifySessionMetadataChanged({ model: 'claude-sonnet-4-6' })
|
||||||
|
|
||||||
|
expect(calls).toEqual(['L1:claude-sonnet-4-6', 'L2:claude-sonnet-4-6'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsubscribed metadata listener does not receive subsequent notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const unsub = setSessionMetadataChangedListener(m => {
|
||||||
|
calls.push(`L1:${m.model ?? 'no-model'}`)
|
||||||
|
})
|
||||||
|
setSessionMetadataChangedListener(m => {
|
||||||
|
calls.push(`L2:${m.model ?? 'no-model'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
notifySessionMetadataChanged({ model: 'opus-4' })
|
||||||
|
expect(calls).toEqual(['L1:opus-4', 'L2:opus-4'])
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
notifySessionMetadataChanged({ model: 'sonnet-4' })
|
||||||
|
expect(calls).toEqual(['L2:sonnet-4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('metadata replay only fires for the subscribing listener', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
notifySessionMetadataChanged({ model: 'haiku-3.5' })
|
||||||
|
|
||||||
|
setSessionMetadataChangedListener(
|
||||||
|
m => calls.push(`L1:${m.model}`),
|
||||||
|
{ replayCurrent: true },
|
||||||
|
)
|
||||||
|
setSessionMetadataChangedListener(
|
||||||
|
m => calls.push(`L2:${m.model}`),
|
||||||
|
{ replayCurrent: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual(['L1:haiku-3.5', 'L2:haiku-3.5'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple permission mode listeners all receive notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
setPermissionModeChangedListener(m => calls.push(`L1:${m}`))
|
||||||
|
setPermissionModeChangedListener(m => calls.push(`L2:${m}`))
|
||||||
|
|
||||||
|
notifyPermissionModeChanged('plan' as never)
|
||||||
|
|
||||||
|
expect(calls).toEqual(['L1:plan', 'L2:plan'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsubscribed permission mode listener does not receive subsequent notifications', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const unsub = setPermissionModeChangedListener(m => calls.push(`L1:${m}`))
|
||||||
|
setPermissionModeChangedListener(m => calls.push(`L2:${m}`))
|
||||||
|
|
||||||
|
notifyPermissionModeChanged('default' as never)
|
||||||
|
expect(calls).toEqual(['L1:default', 'L2:default'])
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
notifyPermissionModeChanged('acceptEdits' as never)
|
||||||
|
expect(calls).toEqual(['L2:acceptEdits'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resetSessionStateForTests clears all listeners and consumers', () => {
|
||||||
|
const stateCalls: string[] = []
|
||||||
|
const metadataCalls: string[] = []
|
||||||
|
const permCalls: string[] = []
|
||||||
|
const sdkCalls: string[] = []
|
||||||
|
|
||||||
|
setSessionStateChangedListener(() => stateCalls.push('s'))
|
||||||
|
setSessionMetadataChangedListener(() => metadataCalls.push('m'))
|
||||||
|
setPermissionModeChangedListener(() => permCalls.push('p'))
|
||||||
|
registerSdkEventConsumer(() => sdkCalls.push('e'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
notifySessionMetadataChanged({ model: 'test' })
|
||||||
|
notifyPermissionModeChanged('plan' as never)
|
||||||
|
|
||||||
|
expect(stateCalls).toEqual(['s'])
|
||||||
|
expect(metadataCalls).toEqual(['m'])
|
||||||
|
expect(permCalls).toEqual(['p'])
|
||||||
|
expect(sdkCalls.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
resetSessionStateForTests()
|
||||||
|
|
||||||
|
stateCalls.length = 0
|
||||||
|
metadataCalls.length = 0
|
||||||
|
permCalls.length = 0
|
||||||
|
sdkCalls.length = 0
|
||||||
|
|
||||||
|
notifySessionStateChanged('idle')
|
||||||
|
notifySessionMetadataChanged({ model: 'after-reset' })
|
||||||
|
notifyPermissionModeChanged('default' as never)
|
||||||
|
|
||||||
|
expect(stateCalls).toEqual([])
|
||||||
|
expect(metadataCalls).toEqual([])
|
||||||
|
expect(permCalls).toEqual([])
|
||||||
|
expect(sdkCalls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sessionState SDK event consumer (direct push)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSessionStateForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SDK consumer receives session_state_changed events directly', () => {
|
||||||
|
const events: Array<{ subtype: string; state: string }> = []
|
||||||
|
registerSdkEventConsumer(event => {
|
||||||
|
events.push({ subtype: event.subtype, state: event.state })
|
||||||
|
})
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
notifySessionStateChanged('requires_action')
|
||||||
|
notifySessionStateChanged('idle')
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ subtype: 'session_state_changed', state: 'running' },
|
||||||
|
{ subtype: 'session_state_changed', state: 'requires_action' },
|
||||||
|
{ subtype: 'session_state_changed', state: 'idle' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple SDK consumers all receive events', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
registerSdkEventConsumer(() => calls.push('C1'))
|
||||||
|
registerSdkEventConsumer(() => calls.push('C2'))
|
||||||
|
registerSdkEventConsumer(() => calls.push('C3'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
|
||||||
|
expect(calls).toEqual(['C1', 'C2', 'C3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsubscribed SDK consumer does not receive subsequent events', () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const unsub = registerSdkEventConsumer(() => calls.push('C1'))
|
||||||
|
registerSdkEventConsumer(() => calls.push('C2'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
expect(calls).toEqual(['C1', 'C2'])
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
notifySessionStateChanged('idle')
|
||||||
|
expect(calls).toEqual(['C2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SDK event contains correct shape matching OpenCode format', () => {
|
||||||
|
const events: Array<Record<string, unknown>> = []
|
||||||
|
registerSdkEventConsumer(event => {
|
||||||
|
events.push(event as unknown as Record<string, unknown>)
|
||||||
|
})
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'session_state_changed',
|
||||||
|
state: 'running',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SDK consumer works independently of state listeners', () => {
|
||||||
|
const stateCalls: string[] = []
|
||||||
|
const sdkCalls: string[] = []
|
||||||
|
|
||||||
|
setSessionStateChangedListener(() => stateCalls.push('state'))
|
||||||
|
registerSdkEventConsumer(() => sdkCalls.push('sdk'))
|
||||||
|
|
||||||
|
notifySessionStateChanged('running')
|
||||||
|
|
||||||
|
expect(stateCalls).toEqual(['state'])
|
||||||
|
expect(sdkCalls).toEqual(['sdk'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SDK consumer is NOT called when only metadata changes', () => {
|
||||||
|
const sdkCalls: string[] = []
|
||||||
|
registerSdkEventConsumer(() => sdkCalls.push('sdk'))
|
||||||
|
|
||||||
|
notifySessionMetadataChanged({ model: 'sonnet-4' })
|
||||||
|
notifyPermissionModeChanged('plan' as never)
|
||||||
|
notifyAutomationStateChanged({ enabled: true, phase: 'standby', next_tick_at: 100, sleep_until: null })
|
||||||
|
|
||||||
|
expect(sdkCalls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -53,23 +53,15 @@ type TaskNotificationSdkEvent = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors notifySessionStateChanged. The CCR bridge already receives this
|
// SessionStateChangedEvent is now emitted directly by sessionState.ts's
|
||||||
// via its own listener; SDK consumers (scmuxd, VS Code) need the same signal
|
// registerSdkEventConsumer() — it bypasses this queue entirely so status
|
||||||
// to know when the main turn's generator is idle vs actively producing.
|
// transitions reach SDK consumers (scmuxd, VS Code) without delay.
|
||||||
// The 'idle' transition fires AFTER heldBackResult flushes and the bg-agent
|
// See SdkEventConsumer in src/utils/sessionState.ts for the type.
|
||||||
// do-while loop exits — so SDK consumers can trust it as the authoritative
|
|
||||||
// "turn is over" signal even when result was withheld for background agents.
|
|
||||||
type SessionStateChangedEvent = {
|
|
||||||
type: 'system'
|
|
||||||
subtype: 'session_state_changed'
|
|
||||||
state: 'idle' | 'running' | 'requires_action'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SdkEvent =
|
export type SdkEvent =
|
||||||
| TaskStartedEvent
|
| TaskStartedEvent
|
||||||
| TaskProgressEvent
|
| TaskProgressEvent
|
||||||
| TaskNotificationSdkEvent
|
| TaskNotificationSdkEvent
|
||||||
| SessionStateChangedEvent
|
|
||||||
|
|
||||||
const MAX_QUEUE_SIZE = 1000
|
const MAX_QUEUE_SIZE = 1000
|
||||||
const queue: SdkEvent[] = []
|
const queue: SdkEvent[] = []
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,7 @@ export type AutomationStateMetadata = {
|
||||||
sleep_until: number | null
|
sleep_until: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
import { isEnvTruthy } from './envUtils.js'
|
|
||||||
import type { PermissionMode } from './permissions/PermissionMode.js'
|
import type { PermissionMode } from './permissions/PermissionMode.js'
|
||||||
import { enqueueSdkEvent } from './sdkEventQueue.js'
|
|
||||||
|
|
||||||
// CCR external_metadata keys — push in onChangeAppState, restore in
|
// CCR external_metadata keys — push in onChangeAppState, restore in
|
||||||
// externalMetadataToAppState.
|
// externalMetadataToAppState.
|
||||||
|
|
@ -68,31 +66,55 @@ type SessionMetadataListenerOptions = {
|
||||||
replayCurrent?: boolean
|
replayCurrent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let stateListener: SessionStateChangedListener | null = null
|
/**
|
||||||
let metadataListener: SessionMetadataChangedListener | null = null
|
* Consumer for session_state_changed SDK events. When registered, every
|
||||||
let permissionModeListener: PermissionModeChangedListener | null = null
|
* status transition pushes an event directly to this consumer (bypassing
|
||||||
|
* the SDK event queue), ensuring external clients see status changes
|
||||||
|
* immediately — not delayed until the next drainSdkEvents() call.
|
||||||
|
*
|
||||||
|
* Aligned with OpenCode's GlobalBus pattern: status events propagate
|
||||||
|
* in real-time without buffering.
|
||||||
|
*/
|
||||||
|
export type SdkEventConsumer = (event: {
|
||||||
|
type: 'system'
|
||||||
|
subtype: 'session_state_changed'
|
||||||
|
state: SessionState
|
||||||
|
}) => void
|
||||||
|
|
||||||
|
const stateListeners = new Set<SessionStateChangedListener>()
|
||||||
|
const metadataListeners = new Set<SessionMetadataChangedListener>()
|
||||||
|
const permissionModeListeners = new Set<PermissionModeChangedListener>()
|
||||||
|
const sdkEventConsumers = new Set<SdkEventConsumer>()
|
||||||
|
|
||||||
export function setSessionStateChangedListener(
|
export function setSessionStateChangedListener(
|
||||||
cb: SessionStateChangedListener | null,
|
cb: SessionStateChangedListener | null,
|
||||||
): void {
|
): () => void {
|
||||||
stateListener = cb
|
if (cb) {
|
||||||
|
stateListeners.add(cb)
|
||||||
|
return () => {
|
||||||
|
stateListeners.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionMetadataChangedListener(
|
export function setSessionMetadataChangedListener(
|
||||||
cb: SessionMetadataChangedListener | null,
|
cb: SessionMetadataChangedListener | null,
|
||||||
options?: SessionMetadataListenerOptions,
|
options?: SessionMetadataListenerOptions,
|
||||||
): void {
|
): () => void {
|
||||||
metadataListener = cb
|
if (cb) {
|
||||||
if (!cb || !options?.replayCurrent) {
|
metadataListeners.add(cb)
|
||||||
return
|
if (options?.replayCurrent) {
|
||||||
|
const snapshot = getSessionMetadataSnapshot()
|
||||||
|
if (Object.keys(snapshot).length > 0) {
|
||||||
|
cb(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
metadataListeners.delete(cb)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return () => {}
|
||||||
const snapshot = getSessionMetadataSnapshot()
|
|
||||||
if (Object.keys(snapshot).length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(snapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -104,8 +126,29 @@ export function setSessionMetadataChangedListener(
|
||||||
*/
|
*/
|
||||||
export function setPermissionModeChangedListener(
|
export function setPermissionModeChangedListener(
|
||||||
cb: PermissionModeChangedListener | null,
|
cb: PermissionModeChangedListener | null,
|
||||||
): void {
|
): () => void {
|
||||||
permissionModeListener = cb
|
if (cb) {
|
||||||
|
permissionModeListeners.add(cb)
|
||||||
|
return () => {
|
||||||
|
permissionModeListeners.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a consumer that receives session_state_changed events directly,
|
||||||
|
* bypassing the SDK event queue. Use this in headless/stream-json mode to
|
||||||
|
* push status transitions immediately to the output stream instead of
|
||||||
|
* waiting for the next drainSdkEvents() call.
|
||||||
|
*
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function registerSdkEventConsumer(cb: SdkEventConsumer): () => void {
|
||||||
|
sdkEventConsumers.add(cb)
|
||||||
|
return () => {
|
||||||
|
sdkEventConsumers.delete(cb)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasPendingAction = false
|
let hasPendingAction = false
|
||||||
|
|
@ -175,7 +218,22 @@ export function notifySessionStateChanged(
|
||||||
details?: RequiresActionDetails,
|
details?: RequiresActionDetails,
|
||||||
): void {
|
): void {
|
||||||
currentState = state
|
currentState = state
|
||||||
stateListener?.(state, details)
|
|
||||||
|
// Notify all registered state listeners (CCR bridge, etc.)
|
||||||
|
for (const listener of stateListeners) {
|
||||||
|
listener(state, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push session_state_changed directly to SDK consumers (bypasses queue).
|
||||||
|
// Aligned with OpenCode: status events propagate immediately via
|
||||||
|
// bus.publish → GlobalBus → SSE, not buffered.
|
||||||
|
for (const consumer of sdkEventConsumers) {
|
||||||
|
consumer({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'session_state_changed',
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Mirror details into external_metadata so GetSession carries the
|
// Mirror details into external_metadata so GetSession carries the
|
||||||
// pending-action context without proto changes. Cleared via RFC 7396
|
// pending-action context without proto changes. Cleared via RFC 7396
|
||||||
|
|
@ -208,30 +266,15 @@ export function notifySessionStateChanged(
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror to the SDK event stream so non-CCR consumers (scmuxd, VS Code)
|
|
||||||
// see the same authoritative idle/running signal the CCR bridge does.
|
|
||||||
// 'idle' fires after heldBackResult flushes — lets scmuxd flip IDLE and
|
|
||||||
// show the bg-task dot instead of a stuck generating spinner.
|
|
||||||
//
|
|
||||||
// Opt-in until CCR web + mobile clients learn to ignore this subtype in
|
|
||||||
// their isWorking() last-message heuristics — the trailing idle event
|
|
||||||
// currently pins them at "Running...".
|
|
||||||
// https://anthropic.slack.com/archives/C093BJBD1CP/p1774152406752229
|
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS)) {
|
|
||||||
enqueueSdkEvent({
|
|
||||||
type: 'system',
|
|
||||||
subtype: 'session_state_changed',
|
|
||||||
state,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifySessionMetadataChanged(
|
export function notifySessionMetadataChanged(
|
||||||
metadata: SessionExternalMetadata,
|
metadata: SessionExternalMetadata,
|
||||||
): void {
|
): void {
|
||||||
applyMetadataUpdate(metadata)
|
applyMetadataUpdate(metadata)
|
||||||
metadataListener?.(metadata)
|
for (const listener of metadataListeners) {
|
||||||
|
listener(metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyAutomationStateChanged(
|
export function notifyAutomationStateChanged(
|
||||||
|
|
@ -246,7 +289,9 @@ export function notifyAutomationStateChanged(
|
||||||
|
|
||||||
currentAutomationState = nextState
|
currentAutomationState = nextState
|
||||||
applyMetadataUpdate({ automation_state: nextState })
|
applyMetadataUpdate({ automation_state: nextState })
|
||||||
metadataListener?.({ automation_state: nextState })
|
for (const listener of metadataListeners) {
|
||||||
|
listener({ automation_state: nextState })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -256,13 +301,16 @@ export function notifyAutomationStateChanged(
|
||||||
* silently bypass them.
|
* silently bypass them.
|
||||||
*/
|
*/
|
||||||
export function notifyPermissionModeChanged(mode: PermissionMode): void {
|
export function notifyPermissionModeChanged(mode: PermissionMode): void {
|
||||||
permissionModeListener?.(mode)
|
for (const listener of permissionModeListeners) {
|
||||||
|
listener(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSessionStateForTests(): void {
|
export function resetSessionStateForTests(): void {
|
||||||
stateListener = null
|
stateListeners.clear()
|
||||||
metadataListener = null
|
metadataListeners.clear()
|
||||||
permissionModeListener = null
|
permissionModeListeners.clear()
|
||||||
|
sdkEventConsumers.clear()
|
||||||
hasPendingAction = false
|
hasPendingAction = false
|
||||||
currentState = 'idle'
|
currentState = 'idle'
|
||||||
currentAutomationState = null
|
currentAutomationState = null
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user