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
7 changes: 7 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,10 @@ jobs:
uses: stateful/runme-action@v2
with:
workflows: test
- name: Test agent widgets and key vault
run: >-
pnpm -C app exec vitest run
src/lib/agents src/lib/keyvault src/components/Agents src/components/KeyVault
src/lib/runtime/sandboxJsKernel.test.ts src/lib/notebookData.test.ts
src/components/Actions/Actions.test.tsx
--maxWorkers=2 --minWorkers=1
6 changes: 5 additions & 1 deletion app/src/components/Actions/ActionOutputItems.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AgentMonitorOutput } from '../Agents/AgentMonitor'
import { AGENT_MONITOR_MIME } from '../../lib/agents/types'
import { HtmlOutput } from './HtmlOutput'
import React from 'react'
import { LinkIcon } from '@heroicons/react/20/solid'
Expand Down Expand Up @@ -86,7 +88,9 @@ export function ActionOutputItemView({

let content: React.ReactNode = null

if (mime === 'text/html') {
if (mime === AGENT_MONITOR_MIME) {
content = <AgentMonitorOutput value={text} />
} else if (mime === 'text/html') {
content = (
<HtmlOutput
html={text}
Expand Down
32 changes: 30 additions & 2 deletions app/src/components/Actions/Actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,31 @@ beforeEach(() => {
})

describe('Actions tabs', () => {
it('toggles agent widget source and render modes without executing or unmounting output', () => {
const cell = create(parser_pb.CellSchema, {
refId: 'widget-cell', kind: parser_pb.CellKind.CODE, languageId: 'javascript', value: 'agents.monitor("sess_test")',
outputs: [create(parser_pb.CellOutputSchema, { items: [create(parser_pb.CellOutputItemSchema, {
mime: 'application/vnd.runme.agent-monitor+json',
data: new TextEncoder().encode(JSON.stringify({version: 1, id: 'saved-widget', sessionId: 'sess_test', pageSize: 50})),
})] })],
})
const stub = new StubCellData(cell)
stub.getRunnerName = () => APPKERNEL_RUNNER_NAME
const view = render(<Action cellData={stub as unknown as CellData} docUri="local://file/widget" isFirst={false} />)
const editor = view.container.querySelector('[data-cell-focus-role="editor"]') as HTMLElement
const output = view.container.querySelector('#widget-output-widget-cell') as HTMLElement
expect(editor.hidden).toBe(true)
const mounted = screen.getByTestId('agent-monitor')
fireEvent.click(screen.getByRole('button', {name: /^Edit$/}))
expect(editor.hidden).toBe(false)
expect(output.hidden).toBe(true)
expect(screen.getByTestId('agent-monitor')).toBe(mounted)
fireEvent.click(screen.getByRole('button', {name: /^Render$/}))
expect(output.hidden).toBe(false)
expect(stub.run).not.toHaveBeenCalled()
view.unmount()
})


it.each([
['url', true, true, true],
Expand Down Expand Up @@ -972,7 +997,8 @@ describe('Actions tabs', () => {
)
expect(await screen.findByText('Feedback from diff')).toBeTruthy()
expect(screen.getByText(/Historical source/)).toBeTruthy()
})
// Multiple rendered edit/reply/resolve cycles exceed 5s on shared CI CPUs.
}, 20_000)

it.each(['rendered', 'code', 'markdown-source'])(
'submits a %s selection through real operation-log persistence after a pending flush and later edit',
Expand Down Expand Up @@ -3808,7 +3834,9 @@ describe('Action component', () => {
message: 'Selected text copied',
tone: 'success',
})
expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull()
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull()
})
selection.removeAllRanges()
})

Expand Down
45 changes: 43 additions & 2 deletions app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AGENT_MONITOR_MIME } from '../../lib/agents/types'
import { outputReferenceSource } from '../../lib/outputReferenceRuntime'
import { OutputReferenceCell } from './OutputReferenceCell'
import {
Expand Down Expand Up @@ -264,7 +265,8 @@ function syncIndicatorPresentation(state: NotebookSyncState | null): {
switch (state?.status) {
case 'not-downloaded':
return {
label: 'Notebook has not been downloaded to this browser. Click to download now.',
label:
'Notebook has not been downloaded to this browser. Click to download now.',
className: 'border border-nb-text-faint bg-transparent',
clickable: true,
}
Expand Down Expand Up @@ -805,6 +807,12 @@ export function Action({
})
const shareTargetUri =
shareTarget.docUri === docUri ? shareTarget.targetUri : null
const [widgetEditing, setWidgetEditing] = useState(false)
const hasWidgetOutput =
cell?.outputs?.some((output) =>
output.items.some((item) => item.mime === AGENT_MONITOR_MIME)
) ?? false
const [widgetSourceChanged, setWidgetSourceChanged] = useState(false)
const [htmlEditRequest, setHtmlEditRequest] = useState(0)
const [markdownEditRequest, setMarkdownEditRequest] = useState(0)
const [pid, setPid] = useState<number | null>(null)
Expand Down Expand Up @@ -914,6 +922,8 @@ export function Action({
if (readOnly) {
return
}
setWidgetEditing(false)
setWidgetSourceChanged(false)
cellData.run()
}, [cellData, readOnly])

Expand Down Expand Up @@ -2133,6 +2143,7 @@ export function Action({
<div
className="overflow-hidden rounded-t-nb-md"
data-cell-focus-role="editor"
hidden={hasWidgetOutput && !widgetEditing}
>
<Editor
commentRanges={commentSourceRanges}
Expand All @@ -2149,6 +2160,7 @@ export function Action({
shouldFocus={isActiveCell && isWindowFocused}
readOnly={readOnly}
onChange={(v) => {
if (hasWidgetOutput) setWidgetSourceChanged(true)
const updated = create(parser_pb.CellSchema, cell)
updated.value = v
updateCellLocal(updated)
Expand All @@ -2158,6 +2170,30 @@ export function Action({
</div>

{/* Minimal toolbar: language + runner selectors + run/trash buttons */}
{hasWidgetOutput && (
<div
id={`widget-mode-${cell.refId}`}
className="flex items-center gap-2 border-b px-3 py-2 text-xs"
>
<button
type="button"
aria-pressed={widgetEditing}
onClick={() => setWidgetEditing(true)}
>
Edit
</button>
<button
type="button"
aria-pressed={!widgetEditing}
onClick={() => setWidgetEditing(false)}
>
Render
</button>
{widgetSourceChanged && (
<span>Source changed — Run to update the monitor.</span>
)}
</div>
)}
<div id={`cell-toolbar-${cell.refId}`} className="cell-toolbar">
<div className="flex items-center gap-3">
<select
Expand Down Expand Up @@ -2319,7 +2355,12 @@ export function Action({
{renderedOutputs}
</div>
)}
{renderedOutputItems}
<div
id={`widget-output-${cell.refId}`}
hidden={hasWidgetOutput && widgetEditing}
>
{renderedOutputItems}
</div>
</div>

{/* Context menu */}
Expand Down
213 changes: 213 additions & 0 deletions app/src/components/Agents/AgentMonitor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { AgentItemView, AgentMonitorOutput } from './AgentMonitor'
import { resetAgentMonitors } from '../../lib/agents/runtime'
import * as runtime from '../../lib/agents/runtime'
import { AgentMonitor } from '../../lib/agents/monitor'
import { keyVault } from '../../lib/keyvault/store'

afterEach(() => {
cleanup()
resetAgentMonitors()
localStorage.clear()
keyVault.refresh()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('agent monitor rendering', () => {
it('supports multiline drafts, explicit send, failure retry, and disabled disconnected input', async () => {
const sendMessage = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(undefined)
const descriptor = {
version: 1 as const,
id: 'composer',
sessionId: 'sess_composer',
pageSize: 50,
}
const model = new AgentMonitor(descriptor, () => ({
sendMessage,
session: async () => ({ status: 'idle' }),
items: async () => ({ data: [], has_more: false, last_id: null }),
turns: async () => ({ data: [], has_more: false, last_id: null }),
stream: async () => ({
close() {},
events: {
[Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }),
},
}),
}))
vi.spyOn(runtime, 'resolveAgentMonitor').mockReturnValue(model)
render(<AgentMonitorOutput value={JSON.stringify(descriptor)} />)
const input = screen.getByRole('textbox', {
name: 'Message the agent',
}) as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'Hello\nagent' } })
expect(
(
screen.getByRole('button', {
name: 'Send',
}) as HTMLButtonElement
).disabled
).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() => expect(model.getSnapshot().connection).toBe('live'))
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
fireEvent.keyDown(input, { key: 'Enter', isComposing: true })
expect(sendMessage).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter' })
await screen.findByRole('alert')
expect(input.value).toBe('Hello\nagent')
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(input.value).toBe(''))
expect(sendMessage.mock.calls[1][2]).toBe(sendMessage.mock.calls[0][2])
expect(screen.getByText('Message sent.')).toBeTruthy()
act(() => model.pause())
})
it('renders Markdown tables and code without executing HTML or loading images', () => {
const { container } = render(
<AgentItemView
item={{
id: 'message',
type: 'message',
role: 'assistant',
phase: 'final_answer',
status: 'completed',
content: [
{
type: 'output_text',
text: '**Done**\n\n|Result|Count|\n|---|---|\n|Passed|5|\n\n```js\nalert(1)\n```\n\n<script>alert(2)</script>\n\n![tracking](https://example.com/image.png)\n\n[bad](javascript:alert(3))',
},
],
}}
/>
)
expect(container.querySelector('strong')?.textContent).toBe('Assistant')
expect(container.querySelector('table')?.textContent).toContain('Passed')
expect(container.querySelector('pre code')?.textContent).toContain(
'alert(1)'
)
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('a')?.getAttribute('href')).not.toMatch(
/^javascript:/
)
})
it('keeps unknown tool payloads inspectable and distinguishes user messages', () => {
render(
<>
<AgentItemView
item={{
id: 'tool',
type: 'future_tool',
status: 'failed',
detail: 'Diagnostic',
}}
/>
<AgentItemView
item={{
id: null,
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Hello' }],
}}
/>
</>
)
expect(screen.getByText('future_tool · failed')).toBeTruthy()
expect(screen.getByText('You')).toBeTruthy()
})
it('does not connect saved output automatically and gives actionable configuration errors', async () => {
const fetch = vi.fn()
vi.stubGlobal('fetch', fetch)
render(
<AgentMonitorOutput
value={JSON.stringify({
version: 1,
id: 'saved',
sessionId: 'sess_saved',
pageSize: 50,
})}
/>
)
expect(fetch).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect((await screen.findByRole('alert')).textContent).toContain(
'Unlock Key Vault'
)
expect(fetch).not.toHaveBeenCalled()
})
it('connects a saved widget after unlocking without rerunning its cell', async () => {
const { webcrypto } = await import('node:crypto')
vi.stubGlobal('crypto', webcrypto)
localStorage.clear()
keyVault.refresh()
await keyVault.unlock('widget unlock passphrase', true)
await keyVault.saveKey('openai-api', 'widget-test-secret')
keyVault.lock()
const fetchMock = vi.fn<
(url: string, options?: RequestInit) => Promise<Response>
>(async (url) => {
if (url.includes('stream=true'))
return new Response(new ReadableStream(), {
headers: { 'Content-Type': 'text/event-stream' },
})
return new Response(
JSON.stringify(
url.endsWith('sess_saved')
? { status: 'idle' }
: { data: [], has_more: false, last_id: null }
)
)
})
vi.stubGlobal('fetch', fetchMock)
render(
<AgentMonitorOutput
value={JSON.stringify({
version: 1,
id: 'unlock-retry',
sessionId: 'sess_saved',
pageSize: 50,
})}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect((await screen.findByRole('alert')).textContent).toContain(
'Unlock Key Vault'
)
expect(fetchMock).not.toHaveBeenCalled()
await act(async () => {
await keyVault.unlock('widget unlock passphrase')
})
// Unlocking does not issue requests until the user retries Connect.
expect(fetchMock).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() =>
expect(screen.getByRole('status').textContent).toContain(
'Connection: live'
)
)
expect(screen.queryByRole('alert')).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(
new Headers(fetchMock.mock.calls[0][1]?.headers).get('Authorization')
).toBe('Bearer widget-test-secret')
act(() => keyVault.lock())
expect(
screen.getByRole('button', { name: 'Resume monitoring' })
).toBeTruthy()
})
it('isolates malformed saved descriptors', () => {
render(<AgentMonitorOutput value='{"version":2}' />)
expect(screen.getByRole('alert').textContent).toContain('Unsupported')
})
})
Loading
Loading