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
74 changes: 74 additions & 0 deletions ui-web/src/features/subagents/SubagentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,80 @@ describe('subagents island, an instance detail', () => {
expect(box.value).toBe('第一句,还有第二句')
})

/* The stop the direct chat was missing: a turn the reader started from this
composer was unstoppable -- the surface had no cancel, and the page's own
stop cannot reach a lane it does not name. */
it('offers a stop while the instance is answering, addressed to its lane', async () => {
const cancelled: Array<[string, string]> = []
await openInstance(inst({ handle: 'slow', status: 'completed', resumable: true }), {
instanceSend: () => new Promise<void>(() => {}),
instanceCancel: async (agent, handle) => { cancelled.push([agent, handle]) },
})
/* Nothing is running yet, so there is nothing to stop. */
expect(document.querySelector('.sasend .go[data-state=stop]')).toBeNull()
const box = document.querySelector('.sasend textarea') as HTMLTextAreaElement
box.value = 'take your time'
await act(async () => {
;(document.querySelector('.sasend .mini') as HTMLButtonElement).click()
await Promise.resolve()
})
const stop = document.querySelector('.sasend .go[data-state=stop]') as HTMLButtonElement
expect(stop).toBeTruthy()
await act(async () => {
stop.click()
})
/* The (agent, handle) pair is the point: the direct lane is registered
under it, and a cancel without the target misses every direct turn. */
expect(cancelled).toEqual([['hermes', 'slow']])
/* The stopped turn's own error frame settles the chat and takes the stop
with it -- the same path a completed turn clears by. */
await act(async () => {
store.directEvent({ agent: 'hermes', handle: 'slow' }, 'error')
await Promise.resolve()
})
await act(async () => {
await Promise.resolve()
})
expect(document.querySelector('.sasend .go[data-state=stop]')).toBeNull()
})

it('draws no stop on a page whose source cannot cancel', async () => {
await openInstance(inst({ handle: 'slow', status: 'completed', resumable: true }), {
instanceSend: () => new Promise<void>(() => {}),
})
const box = document.querySelector('.sasend textarea') as HTMLTextAreaElement
box.value = 'take your time'
await act(async () => {
;(document.querySelector('.sasend .mini') as HTMLButtonElement).click()
await Promise.resolve()
})
expect(store.directChat('hermes', 'slow').busy).toBe(true)
expect(document.querySelector('.sasend .go[data-state=stop]')).toBeNull()
})

it('says why a stop could not be sent', async () => {
await openInstance(inst({ handle: 'slow', status: 'completed', resumable: true }), {
instanceSend: () => new Promise<void>(() => {}),
instanceCancel: async () => {
throw new Error('socket dropped')
},
})
const box = document.querySelector('.sasend textarea') as HTMLTextAreaElement
box.value = 'take your time'
await act(async () => {
;(document.querySelector('.sasend .mini') as HTMLButtonElement).click()
await Promise.resolve()
})
await act(async () => {
;(document.querySelector('.sasend .go[data-state=stop]') as HTMLButtonElement).click()
await Promise.resolve()
})
await act(async () => {
await Promise.resolve()
})
expect(why()).toBe('socket dropped')
})

/* One piece of work, two ways in. The conversation's graph card opens a node
through `openDagNode`; this panel opens its own row. They used to land on
different screens -- and only one of them had the composer, which is how a
Expand Down
22 changes: 22 additions & 0 deletions ui-web/src/features/subagents/SubagentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
)
}

/* The stop square, in the size and rounding the page composer's stop button
holds (composer/store.ts ICON_STOP): one glyph, learned once. */
function StopGlyph(): JSX.Element {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
<rect width="10" height="10" rx="2" />
</svg>
)
}

interface OrderedAgentGroup {
name: string
registered?: SubagentRow
Expand Down Expand Up @@ -357,7 +367,7 @@
/* The mount is the paint: a different run remounts through the key. */
paint(el)
return () => store.setStage(null)
}, [])

Check warning on line 370 in ui-web/src/features/subagents/SubagentsPage.tsx

View workflow job for this annotation

GitHub Actions / ui rpc contract

React Hook useEffect has a missing dependency: 'paint'. Either include it or remove the dependency array. If 'paint' changes too often, find the parent component that defines it and wrap that definition in useCallback
return <div className="satx" ref={box} />
}

Expand Down Expand Up @@ -585,6 +595,18 @@
</>
) : null}
{fail || hold ? <span className="why">{fail || hold}</span> : <span />}
{/* Stop while this instance is answering: the turn a send started is
one the reader can end, on the surface that started it. The send
button keeps its own job beside it -- while the turn runs, a send
queues rather than replaces. The disc is the send button's own
shared class, with the stop state as an attribute, the way the
page composer's go button names its own. */}
{chat.busy && store.canCancel() && (
<button className="go" data-state="stop" title={t('gui.stop')} aria-label={t('gui.stop')}
onClick={() => store.cancelInstanceTurn(open.agent, open.handle)}>
<StopGlyph />
</button>
)}
<button className="go" disabled={!text.trim() && !atts.length} onClick={send} aria-label={t('gui.ws.instance_say')}>
<SendGlyph />
</button>
Expand Down Expand Up @@ -642,7 +664,7 @@
}), [current.status])
useEffect(() => {
if (box.current) store.paintAgentRecord(box.current, current)
}, [current.id, current.kind, current.node, current.run_id, current.status, poll])

Check warning on line 667 in ui-web/src/features/subagents/SubagentsPage.tsx

View workflow job for this annotation

GitHub Actions / ui rpc contract

React Hook useEffect has a missing dependency: 'current'. Either include it or remove the dependency array
return (
<div className="instance-conversation" data-composer="false">
<div className="satx" ref={box} />
Expand Down
19 changes: 19 additions & 0 deletions ui-web/src/features/subagents/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,22 @@ describe('the sub-agent heartbeat', () => {
expect(beat).toHaveBeenCalledTimes(1)
})
})

describe('the direct-turn seam', () => {
it('addresses a stop to the instance lane, carrying the target turn.cancel needs', async () => {
const { sources } = await harness()
const calls: Array<[string, Record<string, unknown>]> = []
await fakeGateway(async (method: string, params: Record<string, unknown>) => {
calls.push([method, params])
return {}
})
await sources.subagents!.instanceCancel!('Raven-Design', 'handle-1')
/* Without the target the lookup is the bare session key, and every direct
turn is registered under direct_lane(...) instead -- the exact miss that
left a direct chat unstoppable. */
expect(calls).toEqual([[
'turn.cancel',
{ session_key: 's1', target: { agent: 'Raven-Design', handle: 'handle-1' } },
]])
})
})
6 changes: 6 additions & 0 deletions ui-web/src/features/subagents/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export const agentsSource: SubagentsSource = {
instanceSend: (agent: string, handle: string, text: string) =>
gateway().call('turn.send', { session_key: openKey(), content: text, target: { agent, handle }, ...mediaOf(text) })
.then(() => undefined),
/* Stop the turn this instance is answering: the same `turn.cancel` the page
composer's stop calls, with the `target` that names the instance's own lane
-- without it the lookup misses every direct turn. */
instanceCancel: (agent: string, handle: string) =>
gateway().call('turn.cancel', { session_key: openKey(), target: { agent, handle } })
.then(() => undefined),
instanceCreate: (agent: string, sessionKey: string) =>
gateway().call('subagents.instance.create', { agent, session_key: sessionKey })
.then((r) => r && r.instance),
Expand Down
20 changes: 20 additions & 0 deletions ui-web/src/features/subagents/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,12 @@ export function canSend(): boolean {
return !!source().instanceSend
}

/* Whether this page can stop a direct turn at all: drawn past a no would be a
click that goes nowhere, the same rule `canSend` serves for the composer. */
export function canCancel(): boolean {
return !!source().instanceCancel
}

/* Coalesced: a direct turn streams, so this arrives per token, and each read is
a whole transcript. One read per window is enough to keep the page moving.

Expand Down Expand Up @@ -673,6 +679,20 @@ export function sendFailOf(agent: string, handle: string): string | null {
return fail && fail.agent === agent && fail.handle === handle ? fail.why : null
}

/* Stop the turn this instance is answering. The busy state clears on the
cancelled turn's own error frame, which arrives tagged with this target and
settles here like a completed turn's -- so a refused cancel (the turn had
already ended) needs no local undo. Queued messages stay queued: a stop is
the engine coming free, and what waited behind the turn runs next, as the
page composer's stop drains its own queue. */
export function cancelInstanceTurn(agent: string, handle: string): void {
const src = source()
if (!src.instanceCancel) return
src.instanceCancel(agent, handle).catch((e: unknown) => {
set({ sendFail: { agent, handle, why: (e as Error)?.message || String(e) } })
})
}

export function forgetInstance(it: InstanceRow): void {
const src = source()
if (!src.instanceForget) return
Expand Down
3 changes: 3 additions & 0 deletions ui-web/src/features/subagents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export interface SubagentsSource {
/* One turn addressed to this instance instead of to the conversation. Optional
like the rest: a server without direct chat has no lane to send it down. */
instanceSend?(agent: string, handle: string, text: string): Promise<void>
/* Stop the turn this instance is answering. Optional like the rest: a server
without direct chat has no lane to cancel. */
instanceCancel?(agent: string, handle: string): Promise<void>
/* Start a fresh instance of one sub-agent. Optional like the rest: a server
without direct chat has none to start. Only an enabled, stateful agent can
be instantiated -- a stateless one answers each turn from nothing, so a
Expand Down
Loading