Keep one persistent socket per profile with live work instead of closing the single socket on every profile swap, so background sessions across profiles keep streaming at once. A gateway registry owns the primary (window) socket plus lazy secondaries (own backoff/reconnect); all feed the same session-keyed event handler. Secondaries are pruned to profiles with a working/needs-input session, the keepalive pings every open backend, and LRU eviction spares freshly-touched backends so the soft cap can't abort a running agent. Approval/sudo/secret prompts are parked per-session (surfaced via the needs-input badge) so a background turn can block without hijacking the foreground. Single-profile users only ever have the primary, so their path is unchanged.
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
|
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
|
import { $activeSessionId } from '@/store/session'
|
|
import { $toolDisclosureStates } from '@/store/tool-view'
|
|
|
|
import { Thread } from './thread'
|
|
|
|
// Regression coverage for the "approval buried behind a collapsed tool group"
|
|
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
|
|
// pending tool's inline ApprovalBar lives inside the group body — which is
|
|
// `hidden` until expanded. A live approval must surface WITHOUT the user
|
|
// expanding anything, so ToolGroupSlot force-opens its body while an approval
|
|
// targeting one of its pending tools is in flight.
|
|
|
|
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
|
|
|
const resizeObservers = new Set<TestResizeObserver>()
|
|
|
|
class TestResizeObserver {
|
|
private target: Element | null = null
|
|
|
|
constructor(private readonly callback: ResizeObserverCallback) {
|
|
resizeObservers.add(this)
|
|
}
|
|
|
|
observe(target: Element) {
|
|
this.target = target
|
|
}
|
|
|
|
unobserve() {}
|
|
|
|
disconnect() {
|
|
resizeObservers.delete(this)
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
window.setTimeout(() => callback(performance.now()), 0)
|
|
)
|
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
|
|
|
Element.prototype.scrollTo = function scrollTo() {}
|
|
|
|
Element.prototype.animate = function animate() {
|
|
return {
|
|
cancel: () => {},
|
|
finished: Promise.resolve()
|
|
} as unknown as Animation
|
|
}
|
|
|
|
function stubOffsetDimension(
|
|
prop: 'offsetHeight' | 'offsetWidth',
|
|
clientProp: 'clientHeight' | 'clientWidth',
|
|
fallback: number
|
|
) {
|
|
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
|
|
|
Object.defineProperty(HTMLElement.prototype, prop, {
|
|
configurable: true,
|
|
get() {
|
|
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
|
}
|
|
})
|
|
}
|
|
|
|
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
|
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
|
|
|
// A running assistant message with two tools: a completed read_file plus a
|
|
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
|
|
// behind a collapsed "Tool actions · 2 steps" header.
|
|
function groupedPendingMessage(): ThreadMessage {
|
|
return {
|
|
id: 'assistant-group-1',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool-call',
|
|
toolCallId: 'read-1',
|
|
toolName: 'read_file',
|
|
args: { path: '/etc/hosts' },
|
|
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
|
result: { content: '127.0.0.1 localhost' }
|
|
},
|
|
{
|
|
type: 'tool-call',
|
|
toolCallId: 'term-1',
|
|
toolName: 'terminal',
|
|
args: { command: 'rm -rf /tmp/x' },
|
|
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
|
|
}
|
|
],
|
|
status: { type: 'running' },
|
|
createdAt,
|
|
metadata: {
|
|
unstable_state: null,
|
|
unstable_annotations: [],
|
|
unstable_data: [],
|
|
steps: [],
|
|
custom: {}
|
|
}
|
|
} as ThreadMessage
|
|
}
|
|
|
|
function GroupHarness({ message }: { message: ThreadMessage }) {
|
|
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
|
messages: [message],
|
|
isRunning: message.status?.type === 'running',
|
|
onNew: async () => {}
|
|
})
|
|
|
|
return (
|
|
<AssistantRuntimeProvider runtime={runtime}>
|
|
<Thread />
|
|
</AssistantRuntimeProvider>
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearAllPrompts()
|
|
$activeSessionId.set('sess-1')
|
|
$toolDisclosureStates.set({})
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
clearAllPrompts()
|
|
$activeSessionId.set(null)
|
|
})
|
|
|
|
describe('ToolGroupSlot approval surfacing', () => {
|
|
it('hides the grouped pending tool body when there is no approval', async () => {
|
|
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
|
|
|
// Group header renders collapsed; the inline approval strip lives in the
|
|
// hidden body, so with no live approval it must not render at all (the
|
|
// ApprovalBar returns null when $approvalRequest is empty).
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Tool actions/)).toBeTruthy()
|
|
})
|
|
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
|
|
})
|
|
|
|
it('force-opens the group body so the approval surfaces without expanding', async () => {
|
|
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
|
|
|
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
|
|
|
// Even though the group defaults collapsed, the live approval forces the
|
|
// body open so the inline controls are visible (and reachable, not in a
|
|
// hidden subtree) immediately.
|
|
await waitFor(() => {
|
|
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
|
|
expect(bar).not.toBeNull()
|
|
// The forced-open group body must not be hidden — assert no ancestor
|
|
// carries the `hidden` attribute that would keep the bar off-screen.
|
|
expect(bar?.closest('[hidden]')).toBeNull()
|
|
})
|
|
})
|
|
})
|