Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function composer(
targetPtyId={null}
agent="claude"
structuredTransport={{
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const RESUME: CommandItem = {

function renderStructuredChat() {
const transport: NativeChatStructuredComposerTransport = {
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
Expand Down
33 changes: 15 additions & 18 deletions src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ vi.mock('./use-native-chat-send-lifecycle', () => ({
import { NativeChatComposer } from './NativeChatComposer'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'

function createOptionsSurface(): SessionOptionsSurface {
return {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
}
}

describe('NativeChatComposer', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -288,12 +297,7 @@ describe('NativeChatComposer', () => {
accepted: false,
error: null
}))
const optionsSurface = {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
} satisfies SessionOptionsSurface
const optionsSurface = createOptionsSurface()
const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[]
const composer = (sessionPending = false) => (
<NativeChatComposer
Expand All @@ -302,6 +306,7 @@ describe('NativeChatComposer', () => {
targetPtyId={null}
agent="codex"
structuredTransport={{
capturePresentation: () => () => true,
send,
dispatchCommand,
optionsSurface,
Expand Down Expand Up @@ -347,14 +352,10 @@ describe('NativeChatComposer', () => {
targetPtyId={null}
agent={agent}
structuredTransport={{
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
},
optionsSurface: createOptionsSurface(),
optionSnapshot: [],
onError: vi.fn(),
runtime: 'local',
Expand Down Expand Up @@ -382,18 +383,14 @@ describe('NativeChatComposer', () => {
targetPtyId={null}
agent="codex"
structuredTransport={{
capturePresentation: () => () => true,
send,
dispatchCommand: vi.fn(async () => ({
handled: false,
accepted: false,
error: null
})),
optionsSurface: {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
},
optionsSurface: createOptionsSurface(),
optionSnapshot: [],
worktreeId: 'wt-1',
onError: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function Harness(props: Props): React.JSX.Element {
isWorking: props.isWorking ?? false,
pending: props.pending ?? [],
transport: props.transport ?? {
capturePresentation: () => () => true,
send: () => true,
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
optionsSurface,
Expand Down Expand Up @@ -388,6 +389,7 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde
sendOptions: () => options
})
const transport: NativeChatStructuredComposerTransport = {
capturePresentation: () => () => true,
send: (text, images, request) =>
sends.send(
text,
Expand Down Expand Up @@ -514,6 +516,7 @@ it('keeps the notice view stable while Continue invokes the latest committed tra
const first = vi.fn(() => true)
const latest = vi.fn(() => true)
const transport: NativeChatStructuredComposerTransport = {
capturePresentation: () => () => true,
send: first,
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
optionsSurface,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ function renderHeldQueue(
): void {
function HeldQueue(): React.JSX.Element {
const owner = useStructuredAgentSessionQueuedMessages({
admit: () => ({ target: { kind: 'local' }, write: write, mutate: async () => null }),
capturePresentation: () => () => true,
enabled: true,
queuedMessages,
queuePause: { reason: 'stopped' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ function renderHeldQueue(
const mutate = vi.fn(async (..._call: [string, string, Record<string, unknown>]) => null)
function HeldQueue(): React.JSX.Element {
const owner = useStructuredAgentSessionQueuedMessages({
admit: () => null,
capturePresentation: () => () => true,
enabled: true,
queuedMessages,
queuePause,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function heldTransport(hostCommand = false): {
})
return {
transport: {
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(() => dispatched),
optionsSurface: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function renderComposer(prompts: string[]): HTMLElement {
targetPtyId={null}
agent="codex"
structuredTransport={{
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CaptureStructuredAgentSessionPresentation } from './use-structured-agent-session-mutate'
import type { StructuredAgentSessionSendRequest } from './structured-agent-session-send-slot'
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command'
Expand Down Expand Up @@ -33,6 +34,7 @@ export type NativeChatOptionPickerRequest = {
}

export type NativeChatStructuredComposerTransport = {
capturePresentation: CaptureStructuredAgentSessionPresentation
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
conversationCommands?: readonly AgentSessionConversationCommand[]
send: (
text: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function fileReferences(document: JSONContent | undefined): string[] {

function transport(send: NativeChatStructuredComposerTransport['send']) {
const value: NativeChatStructuredComposerTransport = {
capturePresentation: () => () => true,
send,
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
optionsSurface: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function transport(
overrides: Partial<NativeChatStructuredComposerTransport> = {}
): NativeChatStructuredComposerTransport {
return {
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => PASS_THROUGH),
optionsSurface: {
Expand Down Expand Up @@ -148,26 +149,33 @@ afterEach(() => cleanup())
describe('structured send racing the next IME composition', () => {
// The regression: the RPC's clear lands while the NEXT composition is live, so the DOM sync
// drops it and settlement used to adopt the sent text back into the composer (#17359).
it('does not resurrect the sent message when the clear lands mid-composition', async () => {
it('preserves the next composition when a typed goal is accepted', async () => {
const dispatch = deferred()
const structured = transport({ dispatchCommand: vi.fn(() => dispatch.promise) })
const structured = transport({
dispatchCommand: vi.fn(async () => PASS_THROUGH).mockReturnValueOnce(dispatch.promise),
threadGoal: { setObjective: vi.fn() }
})
renderComposer(structured)
const pane = `tab-${paneCounter}:structured`
const input = textarea()

changePrompt(input, '안녕')
changePrompt(input, '/goal 안녕')
pressEnter(input)
expect(structured.dispatchCommand).toHaveBeenCalledWith('안녕')
expect(structured.dispatchCommand).toHaveBeenCalledWith('/goal 안녕')

fireEvent.compositionStart(input)
changePrompt(input, '안녕하')
changePrompt(input, '/goal 안녕하')

await act(async () => {
dispatch.resolve(PASS_THROUGH)
dispatch.resolve({ handled: true, accepted: true, error: null })
await dispatch.promise
})
expect(promptValue(input)).toBe('/goal 안녕하')
expect(structured.send).not.toHaveBeenCalled()
fireEvent.compositionEnd(input, { data: '하' })

expect(promptValue(input)).toBe('하')
expect(readNativeChatDraftCache(pane)).toBe('하')

await act(async () => pressEnter(input))
expect(structured.send).toHaveBeenLastCalledWith('하', [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function transport(
setOption: () => Promise<boolean>
): NativeChatStructuredComposerTransport {
return {
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: (text) =>
dispatchStructuredAgentSessionComposerCommand(text, {
Expand Down Expand Up @@ -395,6 +396,7 @@ it('reveals a structured prompt answer at the press, before the host accepts it'
const transcript = useReviewTranscript()
const submits = useStructuredNativeChatSubmitReveal(
{
capturePresentation: () => () => true,
respond: async () => host.promise,
queuedMessages: {
queueCapable: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import {
supportsStructuredAgentSessionQuestionAnswers,
supportsStructuredAgentSessionPromptCancel
Expand All @@ -9,55 +8,57 @@ import {
} from '../../../../shared/agent-session-question-answer'
import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire'
import type { StructuredPromptItem } from './structured-agent-session-message-projection'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import type { StructuredAgentSessionAction } from './use-structured-agent-session-mutate'

export async function respondToStructuredAgentSessionPrompt({
item,
response,
target,
mutate
action
}: {
item: StructuredPromptItem
response: AgentSessionPromptResponse
target: RuntimeClientTarget
mutate: StructuredAgentSessionMutate
action: StructuredAgentSessionAction | null
}): Promise<AgentSessionPromptResult | null> {
if (!action) {
return null
}
const { body } = item
const answers = response.kind === 'answers' ? response.answers : []
const promptTarget = { itemId: item.itemId, expectedRevision: item.revision }
let fields: Record<string, unknown>
if (response.kind === 'option') {
fields = { ...promptTarget, optionId: response.optionId }
} else if (await supportsStructuredAgentSessionQuestionAnswers(target)) {
} else if (await supportsStructuredAgentSessionQuestionAnswers(action.target)) {
// Negotiated before mutate fingerprints the call: older hosts reject the strict field.
fields = { ...promptTarget, answers: response.answers }
fields = { ...promptTarget, answers }
} else {
const optionId =
item.body.kind === 'question'
? legacyAgentSessionSelectedOptionId(item.body, response.answers)
: null
body.kind === 'question' ? legacyAgentSessionSelectedOptionId(body, answers) : null
if (optionId === null) {
return null
}
fields = { ...promptTarget, optionId }
}
return mutate<AgentSessionPromptResult>(
item.body.kind === 'approval'
? 'agentSession.respondToApproval'
: 'agentSession.respondToQuestion',
`agentSession.respondTo:${item.body.kind}`,
return action.mutate<AgentSessionPromptResult>(
body.kind === 'approval' ? 'agentSession.respondToApproval' : 'agentSession.respondToQuestion',
`agentSession.respondTo:${body.kind}`,
fields
)
}

export async function cancelStructuredAgentSessionPrompt(
target: RuntimeClientTarget,
mutate: StructuredAgentSessionMutate,
action: StructuredAgentSessionAction | null,
turnId: string | undefined,
prompt?: { itemId: string; expectedRevision: number }
) {
if (!action) {
return null
}
const promptTarget = prompt && { ...prompt }
const promptSupported =
prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target))
return mutate('agentSession.cancel', 'agentSession.cancel', {
promptTarget !== undefined && (await supportsStructuredAgentSessionPromptCancel(action.target))
return action.mutate('agentSession.cancel', 'agentSession.cancel', {
...(turnId ? { turnId } : {}),
...(promptSupported ? { prompt } : {})
...(promptSupported ? { prompt: promptTarget } : {})
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos

function transport(): NativeChatStructuredComposerTransport {
return {
capturePresentation: () => () => true,
send: vi.fn(() => true),
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
optionsSurface: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ function harness(options: {
imageAttachments?: NativeChatComposerImageAttachment[]
/** The PTY lane has no structured transport at all. */
lane?: 'pty'
capturePresentation?: NativeChatStructuredComposerTransport['capturePresentation']
}) {
const onError = vi.fn()
const onSubmitted = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: submit reads only threadGoal, onError and onSubmitted.
const structuredTransport = {
capturePresentation: options.capturePresentation ?? (() => () => true),
onError,
onSubmitted,
...(options.threadGoal ? { threadGoal: options.threadGoal } : {})
Expand Down Expand Up @@ -255,6 +257,26 @@ describe('composer goal mode', () => {
expect(hook.result.current.goalMode.active).toBe(true)
})

it('consumes an accepted captured goal after closure without touching local controls', async () => {
const reply = Promise.withResolvers<boolean>()
let current = true
const { hook, calls, type, onError } = harness({
draft: '/go',
threadGoal: { setObjective: () => reply.promise },
capturePresentation: () => () => current
})
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
type('Ship it', 0)
act(() => hook.result.current.send())
calls.setCaret.mockClear()
hook.unmount()
current = false
await act(async () => reply.resolve(true))
expect(readNativeChatDraftCache(SCOPE)).toBe('')
expect(calls.setCaret).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})

it('does not send while an image waits to be attached again', () => {
const { hook, calls } = harness({
draft: 'see the screenshot',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,20 @@ export function useNativeChatComposerSubmit(args: {
return
}
structuredTransport.onSubmitted?.()
const isCurrent = structuredTransport.capturePresentation()
const submitted = readNativeChatComposerDraft(draftScopeKey)
void threadGoal.setObjective(objective).then((accepted) => {
if (!accepted) {
return
}
structuredTransport.onError(null)
if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) {
return
}
setCaret(0)
setEntered(false)
if (isCurrent()) {
structuredTransport.onError(null)
setCaret(0)
setEntered(false)
}
})
}, [draft, draftScopeKey, imageAttachments.length, setCaret, structuredTransport, threadGoal])

Expand Down
Loading
Loading