Revert "Preserve OpenCode session across command completion, control SessionS…" (#14943)

This reverts commit 1da1bdc01c.
This commit is contained in:
Jinjing
2026-08-16 12:33:28 -07:00
committed by GitHub
parent bf6dc6fcba
commit ef1224c4f7
10 changed files with 20 additions and 411 deletions
@@ -1,159 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111')
const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222')
describe('AgentHookServer OpenCode lifecycle', () => {
const servers: AgentHookServer[] = []
afterEach(() => {
for (const server of servers) {
server.stop()
}
servers.length = 0
})
async function setup(): Promise<{
server: AgentHookServer
post: (
payload: Record<string, unknown>,
launchToken: string,
paneKey?: string
) => Promise<Response>
}> {
const server = new AgentHookServer()
servers.push(server)
await server.start({ env: 'production' })
const env = server.buildPtyEnv()
return {
server,
post: (payload, launchToken, paneKey = PANE) =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey,
launchToken,
tabId: 'tab-opencode',
worktreeId: 'wt-opencode',
env: 'production',
payload
})
})
}
}
it('accepts Busy after a retired pane receives a root SessionStart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
sessionBoundary: true,
providerSession: { key: 'session_id', id: 'fresh' }
})
])
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' })
])
})
it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token')
expect(server.getStatusSnapshot()).toEqual([])
await post(
{
hook_event_name: 'MessagePart',
role: 'user',
text: 'continue the task',
messageID: 'message-resumed',
sessionID: 'resumed'
},
'resume-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
])
})
it('maps question.asked attention to Waiting after restart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
await post(
{ hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' },
'fresh-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', agentType: 'opencode' })
])
})
it('suppresses stale old-token Busy after a fresh restart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' },
'old-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: '' })
])
})
it('replaces a destination token fence when pane authority transfers', async () => {
const { server, post } = await setup()
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'target-old' },
'target-old-token',
TARGET_PANE
)
server.retirePaneAuthority(TARGET_PANE)
await post(
{ hook_event_name: 'SessionStart', sessionID: 'target-fresh' },
'target-fresh-token',
TARGET_PANE
)
await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token')
server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode')
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' },
'source-token',
TARGET_PANE
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: TARGET_PANE,
providerSession: { key: 'session_id', id: 'source-after-transfer' }
})
])
})
})
+8 -54
View File
@@ -633,7 +633,6 @@ export class AgentHookServer {
private promptSentHashSalt = randomBytes(16).toString('hex')
private closedAgentStatusTabIds = new Set<string>()
private closedAgentStatusPaneKeys = new Set<string>()
private restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
private connectionTimestampWatermarkById = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
@@ -997,13 +996,7 @@ export class AgentHookServer {
private getAgentStatusDisposition(
paneKey: string,
event?: {
hookEventName?: string
isReplay?: boolean
source?: AgentHookSource
hasExplicitPrompt?: boolean
launchToken?: string
}
event?: { hookEventName?: string; isReplay?: boolean }
): 'accept' | 'restart' | 'suppress' {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneRetired =
@@ -1014,38 +1007,17 @@ export class AgentHookServer {
return 'suppress'
}
if (!paneRetired) {
const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey)
if (event && tokenFence) {
const launchToken = event.launchToken?.trim()
if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) {
return 'suppress'
}
}
return 'accept'
}
// Why: a new session boundary or explicit prompt proves a live lifecycle, while its
// token fences follow-up status without restoring retired orchestration authority.
const freshOpenCodePrompt =
event?.source === 'opencode' &&
event.hookEventName === 'MessagePart' &&
event.hasExplicitPrompt === true
// Why: command completion retires launch authority but leaves its shell pane reusable.
// A live SessionStart proves a new agent process owns the retired pane just like a
// fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386).
if (
(event?.hookEventName === 'UserPromptSubmit' ||
event?.hookEventName === 'SessionStart' ||
freshOpenCodePrompt) &&
event?.isReplay !== true
(event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') &&
event.isReplay !== true
) {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
const launchToken = event.launchToken?.trim()
if (launchToken) {
this.restartedStatusLaunchTokenHashByPaneKey.set(
ownerPaneKey,
createHash('sha256').update(launchToken).digest('hex')
)
} else {
this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey)
}
return 'restart'
}
return 'suppress'
@@ -1619,13 +1591,6 @@ export class AgentHookServer {
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
}
const restartedTokenHash =
this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey)
if (restartedTokenHash) {
this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash)
}
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
if (authorityObservation) {
const owner = parsePaneKey(toPaneKey)
@@ -1678,7 +1643,6 @@ export class AgentHookServer {
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
this.clearAssistantMessageRetry(key)
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
@@ -1989,10 +1953,7 @@ export class AgentHookServer {
: undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
hookEventName,
isReplay: envelope.isReplay === true,
source,
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
launchToken: envelope.launchToken
isReplay: envelope.isReplay === true
})
if (statusDisposition === 'suppress') {
return
@@ -2196,10 +2157,7 @@ export class AgentHookServer {
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
hookEventName: normalized.event.hookEventName,
isReplay: normalized.event.isReplay,
source: normalized.event.source,
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
launchToken: normalized.event.launchToken
isReplay: normalized.event.isReplay
})
: 'suppress'
if (normalized.event && statusDisposition !== 'suppress') {
@@ -2282,7 +2240,6 @@ export class AgentHookServer {
this.promptSentDedupeByPaneKey.clear()
this.closedAgentStatusTabIds.clear()
this.closedAgentStatusPaneKeys.clear()
this.restartedStatusLaunchTokenHashByPaneKey.clear()
this.connectionTimestampWatermarkById.clear()
this.legacyPaneKeyAliases.clear()
clearAllListenerCaches(this.state)
@@ -2457,7 +2414,6 @@ export class AgentHookServer {
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.currentAuthorityObservations.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
@@ -2478,7 +2434,6 @@ export class AgentHookServer {
clearPaneCacheState(this.state, resolvedPaneKey)
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
@@ -2488,7 +2443,6 @@ export class AgentHookServer {
clearPaneCacheState(this.state, legacyPaneKey)
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
+1 -3
View File
@@ -56,9 +56,7 @@ describe('MimoCodeHookService buildPtyEnv', () => {
const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js')
expect(existsSync(orcaPlugin)).toBe(true)
const pluginSource = readFileSync(orcaPlugin, 'utf8')
expect(pluginSource).toContain('/hook/mimo-code')
expect(pluginSource).not.toContain('post("SessionStart"')
expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code')
expect(
readFileSync(join(mimocodeHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8')
+1 -1
View File
@@ -68,7 +68,7 @@ export class MimoCodeHookService {
mkdirSync(pluginsDir, { recursive: true })
writeFileSync(
join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE),
getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })
getOpenCodeFamilyPluginSource('/hook/mimo-code')
)
} catch {
return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {}
@@ -131,22 +131,6 @@ describe('OpenCode plugin lifecycle delivery', () => {
})
}
it('maps only root session.created to SessionStart', async () => {
const handler = await loadHandler()
await handler({
event: { type: 'session.created', properties: { info: { id: 'root' } } }
})
await handler({
event: {
type: 'session.created',
properties: { info: { id: 'child', parentID: 'root' } }
}
})
expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }])
})
it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => {
let releaseFirstLookup: (() => void) | undefined
const firstLookup = new Promise<void>((resolve) => {
+2 -19
View File
@@ -36,13 +36,10 @@ function toSafeDirName(id: string): string {
}
export function getOpenCodePluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true })
return getOpenCodeFamilyPluginSource('/hook/opencode')
}
export function getOpenCodeFamilyPluginSource(
hookPathname: string,
options: { emitSessionStart: boolean }
): string {
export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
// Why: the plugin posts PTY environment data from OpenCode to the shared hooks server.
return [
'// Why: process-lifetime guard so a recurring parse error on a malformed',
@@ -838,20 +835,6 @@ export function getOpenCodeFamilyPluginSource(
'',
' const sessionID = event.properties?.sessionID;',
' const updatedPart = event.properties?.part;',
...(options.emitSessionStart
? [
' if (event.type === "session.created") {',
' const info = event.properties?.info;',
' if (!info?.id || info.parentID) return;',
' rememberSessionRoot(info.id, info.id);',
' await enqueueLifecycle(() =>',
' disposed ? undefined : post("SessionStart", { sessionID: info.id })',
' );',
' return;',
' }',
''
]
: []),
' if (',
' event.type === "message.part.updated" &&',
' updatedPart?.type === "tool" &&',
-104
View File
@@ -13092,110 +13092,6 @@ describe('OrcaRuntimeService', () => {
).toBeUndefined()
})
it('keeps OpenCode launch authority while command-finished leaves it in foreground', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-opencode', incarnationId: 'process-1' })
const retireAuthority = vi.fn()
const getForegroundProcess = vi.fn(async () => 'opencode')
const runtime = new OrcaRuntimeService(store, undefined, {
attestAgentHookCompatibilityAuthority: (candidate) => ({
paneKey: candidate.paneKey,
source: 'current_hook'
}),
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-opencode' }),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const terminal = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'opencode',
launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} },
launchAgent: 'opencode'
})
const spawnEnv =
(spawn.mock.calls[0]?.[0] as { env?: Record<string, string> } | undefined)?.env ?? {}
const evidence = {
terminalHandle: terminal.handle,
paneKey: spawnEnv.ORCA_PANE_KEY,
launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN
}
runtime.onPtyData('pty-opencode', '\x1b]133;D;0\x07', 100)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled())
expect(retireAuthority).not.toHaveBeenCalled()
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).not.toBeNull()
})
it('ignores a stale OpenCode foreground result after a newer title observation', async () => {
let resolveForegroundProcess: ((process: string | null) => void) | undefined
const foregroundProcess = new Promise<string | null>((resolve) => {
resolveForegroundProcess = resolve
})
const spawn = vi.fn().mockResolvedValue({ id: 'pty-opencode-race', incarnationId: 'process-1' })
const retireAuthority = vi.fn()
const getForegroundProcess = vi.fn(() => foregroundProcess)
const runtime = new OrcaRuntimeService(store, undefined, {
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-opencode-race' }),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'opencode',
launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} },
launchAgent: 'opencode'
})
runtime.onPtyData('pty-opencode-race', '\x1b]133;D;0\x07', 100)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled())
runtime.onPtyData('pty-opencode-race', '\x1b]0;OpenCode working\x07', 101)
resolveForegroundProcess?.(null)
await foregroundProcess
await Promise.resolve()
expect(retireAuthority).not.toHaveBeenCalled()
})
it('retires only receipted restored PTY authority on command completion and exit', () => {
const retireAuthority = vi.fn()
const runtime = new OrcaRuntimeService(store, undefined, {
+2 -32
View File
@@ -10325,7 +10325,7 @@ export class OrcaRuntimeService {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
return
case 'command-finished':
this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId)
this.retirePtyAgentLaunchAuthority(ptyId)
this.recordTerminalSideEffectFact(ptyId, {
kind: 'command-finished',
exitCode: fact.exitCode
@@ -10614,7 +10614,7 @@ export class OrcaRuntimeService {
this.confirmPtyAgentExit(ptyId)
},
onCommandFinished: (exitCode: number | null) => {
this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId)
this.retirePtyAgentLaunchAuthority(ptyId)
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
},
onBell: () => {
@@ -12742,36 +12742,6 @@ export class OrcaRuntimeService {
}
}
private retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (pty?.launchAgent !== 'opencode') {
this.retirePtyAgentLaunchAuthority(ptyId)
return
}
const titleObservedAt = pty.lastOscTitleAt ?? null
const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0)
if (!foregroundRead) {
this.retirePtyAgentLaunchAuthority(ptyId)
return
}
const incarnationId = pty.incarnationId
void foregroundRead.then((result) => {
const current = this.ptysById.get(ptyId)
if (
current !== pty ||
current.incarnationId !== incarnationId ||
current.lastOscTitleAt !== titleObservedAt ||
result.controller !== this.ptyController
) {
return
}
if (result.available && recognizeAgentProcess(result.process)?.agent === 'opencode') {
return
}
this.retirePtyAgentLaunchAuthority(ptyId)
})
}
async resolveTerminalCwd(handle: string): Promise<string | null> {
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
if (!ptyId) {
@@ -216,15 +216,6 @@ describe('shared agent-hook-listener', () => {
},
'production'
)
const sessionStart = normalizeHookPayload(
state,
'mimo-code',
{
paneKey: PANE_KEY,
payload: { hook_event_name: 'SessionStart', sessionID: 'mimo-session' }
},
'production'
)
expect(message?.payload).toMatchObject({
agentType: 'mimo-code',
@@ -235,7 +226,6 @@ describe('shared agent-hook-listener', () => {
expect(message?.providerSession).toMatchObject({ key: 'session_id', id: 'mimo-session' })
expect(tool?.payload).toMatchObject({ agentType: 'mimo-code', state: 'working' })
expect(idle?.payload).toMatchObject({ agentType: 'mimo-code', state: 'done' })
expect(sessionStart).toBeNull()
})
it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => {
+6 -13
View File
@@ -2437,7 +2437,6 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
case 'amp':
return eventName === 'agent.start'
case 'opencode':
return eventName === 'SessionStart'
case 'mimo-code':
return false
case 'cursor':
@@ -3774,19 +3773,14 @@ function normalizeOpenCodeFamilyEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const resetsTurn =
isNewTurnEvent(source, eventName) ||
(eventName === 'MessagePart' && hookPayload.role === 'user')
const stateName =
eventName === 'SessionBusy' || eventName === 'MessagePart'
? 'working'
: eventName === 'SessionIdle'
? 'done'
: source === 'opencode' && eventName === 'SessionStart'
? 'done'
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
? 'waiting'
: null
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
? 'waiting'
: null
if (!stateName) {
return null
@@ -3796,20 +3790,19 @@ function normalizeOpenCodeFamilyEvent(
state,
paneKey,
extractToolFields(source, eventName, hookPayload),
{ resetOnNewTurn: resetsTurn }
{ resetOnNewTurn: isNewTurnEvent(source, eventName) }
)
return normalizeAgentStatusPayload({
state: stateName,
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: resetsTurn
resetOnNewTurn: isNewTurnEvent(source, eventName)
}),
agentType: source,
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined
lastAssistantMessage: snapshot.lastAssistantMessage
})
}