Send review notes to active agent session

This commit is contained in:
Neil
2026-05-31 04:35:45 -07:00
parent aabcd29d32
commit a2cbb3d664
8 changed files with 576 additions and 8 deletions
+7
View File
@@ -506,6 +506,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
process: await runtime.inspectTerminalProcess(params.terminal)
})
}),
defineMethod({
name: 'terminal.isRunningAgent',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal)
})
}),
defineMethod({
name: 'terminal.rename',
params: TerminalRename,
@@ -16,6 +16,26 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('terminal send RPC', () => {
it('reports whether a terminal handle is running a recognized agent', async () => {
const runtime = stubRuntime({
isTerminalRunningAgent: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.isRunningAgent', {
terminal: 'terminal-1'
})
)
expect(response.ok).toBe(true)
if (!response.ok) {
throw new Error(response.error.message)
}
expect(response.result).toEqual({ isRunningAgent: true })
expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('terminal-1')
})
it('drops desktop input while a mobile client owns the terminal floor', async () => {
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+2
View File
@@ -278,6 +278,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.create',
'terminal.focus',
'terminal.getAutoRestoreFit',
'terminal.isRunningAgent',
'terminal.list',
'terminal.multiplex',
'terminal.read',
@@ -288,6 +289,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.subscribe',
'terminal.unsubscribe',
'terminal.updateViewport',
'terminal.wait',
'ui.get',
'ui.recordFeatureInteraction',
'ui.set',
@@ -74,9 +74,15 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuContent: function DropdownMenuContent(props: Record<string, unknown>) {
return { type: 'DropdownMenuContent', props }
},
DropdownMenuItem: function DropdownMenuItem(props: Record<string, unknown>) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: Record<string, unknown>) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator(props: Record<string, unknown>) {
return { type: 'DropdownMenuSeparator', props }
},
DropdownMenuSub: function DropdownMenuSub(props: Record<string, unknown>) {
return { type: 'DropdownMenuSub', props }
},
@@ -109,8 +115,20 @@ vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({
}
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
vi.mock('@/lib/active-agent-note-send', () => ({
activeAgentNotesSendFailureMessage: (status: string) => status,
sendNotesToActiveAgentSession: vi.fn(),
useCanSendNotesToActiveTerminal: () => false
}))
vi.mock('sonner', () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
message: vi.fn(),
success: vi.fn()
}
}))
function resetHookRuntime(): void {
@@ -291,6 +309,19 @@ describe('NotesSendMenu', () => {
)
})
it('offers the active session action alongside new agent launchers', () => {
const tree = renderMenu()
expect(findByType(tree, 'DropdownMenuItem').props.disabled).toBe(true)
expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({
worktreeId: 'wt-1',
groupId: 'group-1',
prompt: 'prompt-all',
promptDelivery: 'submit-after-ready',
launchSource: 'notes_send'
})
})
it('switches running-agent target mode when a different scope is focused', () => {
const tree = renderMenu({
defaultScopeId: 'file',
@@ -12,9 +12,8 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { cn } from '@/lib/utils'
import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
const ENABLED_SEND_TOOLTIP = 'Send notes to an agent'
@@ -195,10 +194,9 @@ export function NotesSendMenu<TNote>({
<NoteScopeMenuRow label={scope.label} count={scope.notes.length} />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<QuickLaunchAgentMenuItems
<ReviewNotesSendMenuContent
worktreeId={worktreeId}
groupId={groupId}
onFocusTerminal={focusTerminalTabSurface}
prompt={scope.prompt}
promptDelivery="submit-after-ready"
launchSource="notes_send"
@@ -209,10 +207,9 @@ export function NotesSendMenu<TNote>({
))}
</>
) : (
<QuickLaunchAgentMenuItems
<ReviewNotesSendMenuContent
worktreeId={worktreeId}
groupId={groupId}
onFocusTerminal={focusTerminalTabSurface}
prompt={defaultScope?.prompt ?? ''}
promptDelivery="submit-after-ready"
launchSource="notes_send"
@@ -0,0 +1,80 @@
import React, { useCallback } from 'react'
import { SquareTerminal } from 'lucide-react'
import { toast } from 'sonner'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
activeAgentNotesSendFailureMessage,
sendNotesToActiveAgentSession,
useCanSendNotesToActiveTerminal
} from '@/lib/active-agent-note-send'
import type { LaunchSource } from '../../../../shared/telemetry-events'
export function ReviewNotesSendMenuContent({
worktreeId,
groupId,
prompt,
promptDelivery = 'submit-after-ready',
launchSource = 'notes_send',
onPromptDelivered
}: {
worktreeId: string
groupId: string
prompt: string
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
launchSource?: LaunchSource
onPromptDelivered?: () => void
}): React.JSX.Element {
const hasPrompt = prompt.trim().length > 0
const canSendToActiveTerminal = useCanSendNotesToActiveTerminal(worktreeId)
const sendToActiveAgent = useCallback(() => {
const pending = toast.loading('Sending notes to active agent...')
void sendNotesToActiveAgentSession({ worktreeId, prompt })
.then((result) => {
if (result.status === 'sent') {
onPromptDelivered?.()
toast.success('Notes sent to active agent.')
return
}
toast.message(activeAgentNotesSendFailureMessage(result.status))
})
.catch((error) => {
console.error('Failed to send notes to active agent:', error)
toast.error('Could not send notes to the active agent.')
})
.finally(() => {
toast.dismiss(pending)
})
}, [worktreeId, prompt, onPromptDelivered])
return (
<>
<DropdownMenuLabel>Send notes to</DropdownMenuLabel>
<DropdownMenuItem
disabled={!hasPrompt || !canSendToActiveTerminal}
onSelect={sendToActiveAgent}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<SquareTerminal className="size-3.5" />
Active agent session
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>New agent</DropdownMenuLabel>
<QuickLaunchAgentMenuItems
worktreeId={worktreeId}
groupId={groupId}
onFocusTerminal={focusTerminalTabSurface}
prompt={prompt}
promptDelivery={promptDelivery}
launchSource={launchSource}
onPromptDelivered={onPromptDelivered}
/>
</>
)
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getActiveTerminalNoteTarget,
sendNotesToActiveAgentSession
} from './active-agent-note-send'
const testState = vi.hoisted(() => ({
appState: {
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
activeTabId: 'tab-1',
activeTabIdByWorktree: {},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1' }]
},
terminalLayoutsByTabId: {
'tab-1': { activeLeafId: 'leaf-1' }
},
settings: {}
} as {
activeWorktreeId: string | null
activeTabType: 'terminal' | 'editor'
activeTabId: string | null
activeTabIdByWorktree: Record<string, string | null>
tabsByWorktree: Record<string, { id: string }[]>
terminalLayoutsByTabId: Record<string, { activeLeafId: string | null }>
settings: Record<string, unknown>
},
callRuntimeRpc: vi.fn(),
getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' }))
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: typeof testState.appState) => unknown) => selector(testState.appState),
{
getState: () => testState.appState
}
)
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: testState.callRuntimeRpc,
getActiveRuntimeTarget: testState.getActiveRuntimeTarget
}))
describe('active agent note send', () => {
beforeEach(() => {
testState.appState = {
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
activeTabId: 'tab-1',
activeTabIdByWorktree: {},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1' }]
},
terminalLayoutsByTabId: {
'tab-1': { activeLeafId: 'leaf-1' }
},
settings: {}
}
testState.callRuntimeRpc.mockReset()
testState.getActiveRuntimeTarget.mockClear()
testState.getActiveRuntimeTarget.mockReturnValue({ kind: 'local' })
})
it('resolves the current worktree terminal pane from renderer state', () => {
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
tabId: 'tab-1',
leafId: 'leaf-1'
})
})
it('uses the per-worktree active tab fallback', () => {
testState.appState.activeTabId = null
testState.appState.activeTabIdByWorktree = { 'wt-1': 'tab-1' }
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
tabId: 'tab-1',
leafId: 'leaf-1'
})
})
it('uses the last active terminal tab while the user is viewing editor notes', () => {
testState.appState.activeTabType = 'editor'
testState.appState.activeTabIdByWorktree = { 'wt-1': 'tab-1' }
expect(getActiveTerminalNoteTarget(testState.appState, 'wt-1')).toEqual({
tabId: 'tab-1',
leafId: 'leaf-1'
})
})
it('sends notes only after the active terminal is verified as an idle agent', async () => {
testState.callRuntimeRpc.mockImplementation(async (_target, method, params) => {
if (method === 'terminal.list') {
return {
terminals: [
{
handle: 'term-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-1',
leafId: 'leaf-1',
title: 'Codex',
connected: true,
writable: true,
lastOutputAt: 1,
preview: ''
}
],
totalCount: 1,
truncated: false
}
}
if (method === 'terminal.isRunningAgent') {
return { isRunningAgent: true }
}
if (method === 'terminal.wait') {
return {
wait: {
handle: 'term-1',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
}
}
}
if (method === 'terminal.send') {
return { send: { handle: 'term-1', accepted: true, bytesWritten: params.text.length } }
}
throw new Error(`unexpected method ${method}`)
})
await expect(
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'File: src/app.ts' })
).resolves.toEqual({ status: 'sent' })
expect(testState.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'local' },
'terminal.list',
{ worktree: 'id:wt-1', limit: 200 },
{ timeoutMs: 15000 }
)
expect(testState.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'local' },
'terminal.send',
{ terminal: 'term-1', text: 'File: src/app.ts', enter: true },
{ timeoutMs: 15000 }
)
})
it('does not write notes when the active terminal is not an agent', async () => {
testState.callRuntimeRpc.mockImplementation(async (_target, method) => {
if (method === 'terminal.list') {
return {
terminals: [
{
handle: 'term-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-1',
leafId: 'leaf-1',
title: 'zsh',
connected: true,
writable: true,
lastOutputAt: 1,
preview: ''
}
],
totalCount: 1,
truncated: false
}
}
if (method === 'terminal.isRunningAgent') {
return { isRunningAgent: false }
}
throw new Error(`unexpected method ${method}`)
})
await expect(
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
).resolves.toEqual({ status: 'no-agent' })
expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith(
expect.anything(),
'terminal.send',
expect.anything(),
expect.anything()
)
})
it('does not write notes when the active agent is not ready', async () => {
testState.callRuntimeRpc.mockImplementation(async (_target, method) => {
if (method === 'terminal.list') {
return {
terminals: [
{
handle: 'term-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'main',
tabId: 'tab-1',
leafId: 'leaf-1',
title: 'Codex',
connected: true,
writable: true,
lastOutputAt: 1,
preview: ''
}
],
totalCount: 1,
truncated: false
}
}
if (method === 'terminal.isRunningAgent') {
return { isRunningAgent: true }
}
if (method === 'terminal.wait') {
throw new Error('timeout')
}
throw new Error(`unexpected method ${method}`)
})
await expect(
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
).resolves.toEqual({ status: 'not-ready' })
expect(testState.callRuntimeRpc).not.toHaveBeenCalledWith(
expect.anything(),
'terminal.send',
expect.anything(),
expect.anything()
)
})
it('does not call runtime when no terminal pane is known for the worktree', async () => {
testState.appState.activeTabType = 'editor'
testState.appState.activeTabIdByWorktree = {}
await expect(
sendNotesToActiveAgentSession({ worktreeId: 'wt-1', prompt: 'notes' })
).resolves.toEqual({ status: 'no-active-terminal' })
expect(testState.callRuntimeRpc).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,181 @@
import type {
RuntimeTerminalListResult,
RuntimeTerminalSend,
RuntimeTerminalWait
} from '../../../shared/runtime-types'
import type { AppState } from '@/store/types'
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
const ACTIVE_AGENT_SEND_TIMEOUT_MS = 8000
const ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS = 15000
const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200
export type ActiveTerminalNoteTarget = {
tabId: string
leafId: string
}
export type ActiveAgentNotesSendStatus =
| 'sent'
| 'empty'
| 'no-active-terminal'
| 'no-agent'
| 'not-ready'
| 'not-writable'
export type ActiveAgentNotesSendResult = {
status: ActiveAgentNotesSendStatus
}
type ActiveTerminalNoteTargetState = {
activeWorktreeId: AppState['activeWorktreeId']
activeTabType: AppState['activeTabType']
activeTabId: AppState['activeTabId']
activeTabIdByWorktree: AppState['activeTabIdByWorktree']
tabsByWorktree: Record<string, readonly { id: string }[] | undefined>
terminalLayoutsByTabId: Record<string, { activeLeafId: string | null } | undefined>
}
export function getActiveTerminalNoteTarget(
state: ActiveTerminalNoteTargetState,
worktreeId: string
): ActiveTerminalNoteTarget | null {
if (state.activeWorktreeId !== worktreeId) {
return null
}
const tabId =
state.activeTabType === 'terminal'
? (state.activeTabId ?? state.activeTabIdByWorktree[worktreeId])
: state.activeTabIdByWorktree[worktreeId]
if (!tabId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) {
return null
}
const leafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId
return leafId ? { tabId, leafId } : null
}
export function useCanSendNotesToActiveTerminal(worktreeId: string): boolean {
return useAppStore((state) => getActiveTerminalNoteTarget(state, worktreeId) !== null)
}
export async function sendNotesToActiveAgentSession({
worktreeId,
prompt,
timeoutMs = ACTIVE_AGENT_SEND_TIMEOUT_MS
}: {
worktreeId: string
prompt: string
timeoutMs?: number
}): Promise<ActiveAgentNotesSendResult> {
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
return { status: 'empty' }
}
const state = useAppStore.getState()
const noteTarget = getActiveTerminalNoteTarget(state, worktreeId)
if (!noteTarget) {
return { status: 'no-active-terminal' }
}
const runtimeTarget = getActiveRuntimeTarget(state.settings)
const terminal = await findActiveRuntimeTerminal(runtimeTarget, worktreeId, noteTarget)
if (!terminal) {
return { status: 'no-active-terminal' }
}
// Why: sending notes submits with Enter, so only the runtime's agent/idle
// checks can authorize it; tab labels and renderer state are not enough.
const agentCheck = await callRuntimeRpc<{ isRunningAgent: boolean }>(
runtimeTarget,
'terminal.isRunningAgent',
{ terminal: terminal.handle },
{ timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS }
)
if (!agentCheck.isRunningAgent) {
return { status: 'no-agent' }
}
try {
const { wait } = await callRuntimeRpc<{ wait: RuntimeTerminalWait }>(
runtimeTarget,
'terminal.wait',
{ terminal: terminal.handle, for: 'tui-idle', timeoutMs },
{ timeoutMs: timeoutMs + 5000 }
)
if (!wait.satisfied) {
return { status: 'not-ready' }
}
} catch (error) {
if (isRuntimeTerminalUnavailable(error)) {
return { status: 'no-active-terminal' }
}
if (isRuntimeTimeout(error)) {
return { status: 'not-ready' }
}
throw error
}
const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
runtimeTarget,
'terminal.send',
{ terminal: terminal.handle, text: trimmedPrompt, enter: true },
{ timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS }
)
return send.accepted ? { status: 'sent' } : { status: 'not-writable' }
}
export function activeAgentNotesSendFailureMessage(status: ActiveAgentNotesSendStatus): string {
switch (status) {
case 'empty':
return 'No notes to send.'
case 'no-active-terminal':
return 'Open the agent terminal in this worktree, then send the notes again.'
case 'no-agent':
return 'The active terminal is not a recognized agent session.'
case 'not-ready':
return 'The active agent was not ready for input yet.'
case 'not-writable':
return 'The active terminal did not accept the notes.'
case 'sent':
return ''
}
}
async function findActiveRuntimeTerminal(
runtimeTarget: ReturnType<typeof getActiveRuntimeTarget>,
worktreeId: string,
noteTarget: ActiveTerminalNoteTarget
): Promise<RuntimeTerminalListResult['terminals'][number] | null> {
const { terminals } = await callRuntimeRpc<RuntimeTerminalListResult>(
runtimeTarget,
'terminal.list',
// Why: worktree ids can look like branch names or paths; the runtime selector
// accepts raw values, but the id: prefix keeps the lookup unambiguous.
{ worktree: `id:${worktreeId}`, limit: ACTIVE_AGENT_TERMINAL_LIST_LIMIT },
{ timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS }
)
return (
terminals.find(
(terminal) => terminal.tabId === noteTarget.tabId && terminal.leafId === noteTarget.leafId
) ?? null
)
}
function isRuntimeTimeout(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes('timeout')
}
function isRuntimeTerminalUnavailable(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return (
message.includes('terminal_handle_stale') ||
message.includes('terminal_exited') ||
message.includes('terminal_gone') ||
message.includes('no_active_terminal')
)
}