fix(chat): authorize mobile commands and bound clear-chain projection

This commit is contained in:
Merge Sim
2026-09-06 18:05:15 -07:00
parent 672d05c5b1
commit 539e91d55e
7 changed files with 109 additions and 22 deletions
@@ -55,22 +55,33 @@ export class StructuredConversationCommandController {
const store = this.context().deps.store
const records = store.listRecords()
const visible = new Set(store.listVisibleSessionIds())
return records.flatMap((record) => {
let command = record.conversationCommand
let sessionId: string | undefined
const visited = new Set([record.sessionId])
while (
command?.command === 'clear' &&
command.phase === 'committed' &&
command.replacementSessionId
) {
sessionId = command.replacementSessionId
if (visited.has(sessionId)) {
return []
const byId = new Map(records.map((record) => [record.sessionId, record]))
const destinations = new Map<string, string | null>()
const destination = (source: string): string | null => {
const path = new Set<string>()
let current = source
while (!destinations.has(current) && !path.has(current)) {
path.add(current)
const command = byId.get(current)?.conversationCommand
if (
command?.command !== 'clear' ||
command.phase !== 'committed' ||
!command.replacementSessionId
) {
destinations.set(current, current)
break
}
visited.add(sessionId)
command = store.getRecord(sessionId)?.conversationCommand
current = command.replacementSessionId
}
const target = destinations.get(current) ?? null
for (const id of path) {
destinations.set(id, target)
}
return target
}
return records.flatMap((record) => {
const target = destination(record.sessionId)
const sessionId = target !== record.sessionId ? target : null
// Explicit history reveals remain readable; closed replacements stay closed.
return sessionId && visible.has(sessionId) && !visible.has(record.sessionId)
? [
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { StructuredConversationCommandController } from './structured-conversation-command-controller'
function replacements(records: AgentSessionRecord[], visible: string[]) {
const store = {
listRecords: () => records,
listVisibleSessionIds: () => visible,
getRecord: (id: string) => records.find((record) => record.sessionId === id)
}
const controller = new StructuredConversationCommandController(
() => ({ deps: { store } }) as never,
{} as never
)
return controller.replacements()
}
function record(id: string, next?: string): AgentSessionRecord {
return {
sessionId: id,
provider: 'codex',
location: { workspaceId: 'folder' },
conversationCommand: next
? { command: 'clear', phase: 'committed', replacementSessionId: next }
: undefined
} as AgentSessionRecord
}
describe('conversation replacement projection', () => {
it('visits a long clear chain only once per snapshot', () => {
const reads = vi.fn()
const records = Array.from({ length: 200 }, (_, index) => {
const entry = record(String(index), index < 199 ? String(index + 1) : undefined)
const command = entry.conversationCommand
Object.defineProperty(entry, 'conversationCommand', {
get: () => {
reads()
return command
}
})
return entry
})
const result = replacements(records, ['199'])
expect(result).toHaveLength(199)
expect(result.every((entry) => entry.sessionId === '199')).toBe(true)
expect(reads.mock.calls.length).toBeLessThanOrEqual(records.length * 2)
})
it('keeps revealed history and closed chains out, and ignores cycles', () => {
const records = [
record('a', 'b'),
record('b', 'c'),
record('c'),
record('x', 'y'),
record('y', 'x')
]
expect(replacements(records, ['b', 'c', 'x'])).toEqual([
{ sourceSessionId: 'a', sessionId: 'c', workspaceId: 'folder', agent: 'codex' }
])
expect(replacements(records, [])).toEqual([])
})
})
@@ -167,6 +167,7 @@ describe('mobile RPC allowlist', () => {
'agentSession.setOption',
'agentSession.handoffStatus',
'agentSession.options',
'agentSession.conversationCommand',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
@@ -214,6 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'agentSession.setOption',
'agentSession.handoffStatus',
'agentSession.options',
'agentSession.conversationCommand',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useLayoutEffect, useRef } from 'react'
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import type { AgentType } from '../../../../shared/agent-status-types'
@@ -36,7 +36,9 @@ export function useNativeChatStructuredComposerSend({
attachments?: readonly NativeChatComposerImageAttachment[]
) => void {
const composition = useRef({ draft, imageAttachments })
composition.current = { draft, imageAttachments }
useLayoutEffect(() => {
composition.current = { draft, imageAttachments }
}, [draft, imageAttachments])
return useCallback(
(text: string, attachments = imageAttachments): void => {
if (!structuredTransport) {
@@ -11,12 +11,9 @@ export function callStructuredAgentSession<TResult>(
method: string,
params?: unknown
): Promise<TResult> {
return callRuntimeRpc<TResult>(
target,
method,
params,
method === 'agentSession.conversationCommand' ? { timeoutMs: 195_000 } : undefined
)
return method === 'agentSession.conversationCommand'
? callRuntimeRpc<TResult>(target, method, params, { timeoutMs: 195_000 })
: callRuntimeRpc<TResult>(target, method, params)
}
async function subscribeStructuredAgentSessionMethod<TEvent>(
@@ -68,6 +68,11 @@ const STRUCTURED_CALLS: {
hostMethod: 'attach',
result: { ok: true, replayed: false, value: { sessionId: SESSION } }
},
{
method: 'agentSession.conversationCommand',
hostMethod: 'conversationCommand',
result: { ok: true, value: { command: 'compact', state: 'completed' } }
},
{ method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } },
{ method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } },
{ method: 'agentSession.close', hostMethod: 'close', result: { ok: true } },
@@ -210,6 +215,10 @@ function paramsFor(method: string): unknown {
return createIntentParams()
case 'agentSession.ensure':
return attachParams(fence)
case 'agentSession.conversationCommand': {
const fields = { command: 'compact' }
return { envelope: envelope({ method, fields, fence }), ...fields }
}
case 'agentSession.send':
return sendParams('hi', fence)
case 'agentSession.cancel':
@@ -323,6 +332,10 @@ function structuredHostStub(): Record<string, ReturnType<typeof vi.fn>> {
// supports creating there. A real host always answers; leaving it unstubbed made every
// `ensure` refuse for the harness's own reason rather than the location's.
supportsCreate: vi.fn(() => true),
conversationCommand: vi.fn(async () => ({
ok: true,
value: { command: 'compact', state: 'completed' }
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),