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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/assistant/gateway/static/app/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/assistant/screen_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def __init__(self, config, catalog):
self.root = config.workspace_dir
self.catalog = catalog

async def read(self, name: str, context: ConversationContext) -> str:
if name != "screens":
return await super().read(name, context)
return f'<skill_content name="screens">\n{INSTRUCTIONS.strip()}\n</skill_content>'

async def execute(
self,
name: str,
Expand Down
8 changes: 8 additions & 0 deletions tests/test_gateway_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def test_agent_creates_instances_and_screen_then_layout_edits_survive_restart(pa
initial = screen("market.card-instance.yaml")
models = ScriptedModels(
lambda cfg, model: [
ToolCallEvent(name="load_skill", arguments='{"name":"screens"}'),
screen_call(
"save_instance",
{
Expand Down Expand Up @@ -205,6 +206,13 @@ def test_agent_creates_instances_and_screen_then_layout_edits_survive_restart(pa
assert response.status_code == 200, response.text
assert view(client, pid)["message"]["component"]["children"] == ["second", "first"]
history = replay(client, pid)
assert not any(event["type"].endswith("ToolErrorEvent") for event in history)
loaded = next(
event["data"]
for event in history
if event["type"].endswith("ToolResultEvent") and event["data"]["name"] == "load_skill"
)
assert '<skill_content name="screens">' in loaded["result"]["data"]["parts"][0]["content"]
assert not any(event["type"].endswith("A2UISurface") for event in history)
with TestClient(create_app(make_manager(paths, persist=True))) as client:
assert view(client, pid)["message"]["component"]["children"] == ["second", "first"]
Expand Down
30 changes: 30 additions & 0 deletions tests/test_screen_skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Screens instructions load through the agent's progressively disclosed skill tools."""

import pytest
from ag2.context import ConversationContext
from ag2.events import ToolCallEvent, ToolErrorEvent
from ag2.stream import MemoryStream

from assistant.agent import build_skills_plugin, build_skills_runtime
from assistant.config import Config


@pytest.mark.parametrize("snapshot", [False, True])
async def test_screen_skill_loads_instructions_through_plugin(paths, snapshot):
config = Config.for_paths(paths)
plugin = build_skills_plugin(config, build_skills_runtime(config), snapshot=snapshot)
load = next(tool for tool in plugin._tools if tool.name == "load_skill")

result = await load(
ToolCallEvent(name="load_skill", arguments='{"name":"screens"}'),
ConversationContext(stream=MemoryStream()),
)

assert not isinstance(result, ToolErrorEvent)
body = result.result.parts[0].content
assert '<skill_content name="screens">' in body
assert 'script="save_instance"' in body
assert 'script="save_screen"' in body
assert "list_screens and read_screen" in body
assert "show_instance" in body
assert "</skill_content>" in body
12 changes: 7 additions & 5 deletions web/src/lib/a2ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ test('a data-model update grows the bound array the layout already repeats over'
version: 'v1.0',
updateDataModel: { surfaceId: 's1', path: '/trip', value: { legs: [{ day: 'Mon' }, { day: 'Tue' }] } },
})
assert.equal(childSlots(surface.component.children, surface.data).length, 2)
assert.equal(a2uiValue({ path: './day' }, surface.data, '/trip/legs/1'), 'Tue')
const updated = items[0] as Extract<ThreadItem, { kind: 'a2ui' }>
assert.equal(childSlots(updated.component.children, updated.data).length, 2)
assert.equal(a2uiValue({ path: './day' }, updated.data, '/trip/legs/1'), 'Tue')
})

test('a button in a repeated row submits the item it belongs to', () => {
Expand Down Expand Up @@ -239,8 +240,9 @@ test('a card drawn as primitives is titled by the data model that arrives after
version: 'v1.0',
updateDataModel: { surfaceId: 's1', path: '/title', value: 'Ship it' },
})
assert.equal(surface.title, 'Ship it')
assert.equal(a2uiValue({ path: '/title' }, surface.data), 'Ship it')
const updated = items[0] as Extract<ThreadItem, { kind: 'a2ui' }>
assert.equal(updated.title, 'Ship it')
assert.equal(a2uiValue({ path: '/title' }, updated.data), 'Ship it')
})

test('a surface the renderer has never seen is titled and filled like any other', () => {
Expand All @@ -260,7 +262,7 @@ test('a surface the renderer has never seen is titled and filled like any other'
version: 'v1.0',
updateDataModel: { surfaceId: 's1', path: '/title', value: 'Tea, ranked' },
})
assert.equal(surface.title, 'Tea, ranked')
assert.equal((items[0] as Extract<ThreadItem, { kind: 'a2ui' }>).title, 'Tea, ranked')
})

test('two instances of one card read their own rows, not each other\'s', () => {
Expand Down
7 changes: 6 additions & 1 deletion web/src/lib/a2ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,12 @@ function ensureSurface(
catalogId: string | undefined,
version: string,
): A2UIItem {
let item = items.find((i): i is A2UIItem => i.kind === 'a2ui' && i.surfaceId === surfaceId)
const index = items.findIndex((i) => i.kind === 'a2ui' && i.surfaceId === surfaceId)
let item = index < 0 ? undefined : items[index] as A2UIItem
if (item) {
item = { ...item, messages: [...(item.messages || [])] }
items[index] = item
}
if (!item) {
item = {
id: nextItemId(),
Expand Down
52 changes: 52 additions & 0 deletions web/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,58 @@ const drawn = (id: string): WireEvent => ({
})
const replies = (items: ThreadItem[]) => items.filter((i) => i.kind === 'agent')

test('streamed surface updates publish new items while preserving the mounted card identity', () => {
const items: ThreadItem[] = []
foldEvent(items, drawn('calendar'))
const empty = itemOfKind(items[0], 'a2ui')
foldEvent(items, {
type: 'ag2.a2ui.A2UIMessageEvent',
data: { message: { updateComponents: { surfaceId: 'calendar', components: [
{ id: 'root', component: 'Text', text: { path: '/title' } },
] } } },
})
const layout = itemOfKind(items[0], 'a2ui')
assert.notEqual(layout, empty)
assert.equal(layout.id, empty.id)
assert.equal(layout.component.component, 'Text')
assert.deepEqual(empty.component, {})

foldEvent(items, {
type: 'ag2.a2ui.A2UIMessageEvent',
data: { message: { updateDataModel: { surfaceId: 'calendar', path: '/title', value: 'Lessons' } } },
})
const filled = itemOfKind(items[0], 'a2ui')
assert.notEqual(filled, layout)
assert.equal(filled.id, empty.id)
assert.equal(filled.data.title, 'Lessons')
assert.equal(layout.data.title, undefined)
assert.equal(empty.messages?.length, 1)
assert.equal(filled.messages?.length, 3)
})

test('durable surface and saved data updates replace the rendered item without duplicating it', () => {
const items: ThreadItem[] = []
foldEvent(items, drawn('calendar'))
const empty = itemOfKind(items[0], 'a2ui')
foldEvent(items, surface('calendar'))
const rendered = itemOfKind(items[0], 'a2ui')
assert.notEqual(rendered, empty)
assert.equal(rendered.id, empty.id)
assert.equal(rendered.component.component, 'Card')
assert.deepEqual(empty.component, {})

foldEvent(items, {
type: 'assistant.events.A2UISurfaceDataUpdated',
data: { surface_id: 'calendar', data: { title: 'Fresh lessons' } },
})
const updated = itemOfKind(items[0], 'a2ui')
assert.notEqual(updated, rendered)
assert.equal(updated.id, empty.id)
assert.deepEqual(updated.data, { title: 'Fresh lessons' })
assert.deepEqual(rendered.data, {})
assert.equal(items.length, 1)
})

test('a turn that answers with a surface alone says nothing, and is over', () => {
const items: ThreadItem[] = []
foldEvent(items, user('pack for Berlin'))
Expand Down
12 changes: 9 additions & 3 deletions web/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,12 @@ function fold(items: ThreadItem[], type: HandledEvent, data: Record<string, unkn
case 'EphemeralCard':
case 'A2UISurface': {
const d = EventData.A2UISurface.parse(data)
let item = items.find((i): i is ItemOf<'a2ui'> => i.kind === 'a2ui' && i.surfaceId === d.surface_id)
const index = items.findIndex((i) => i.kind === 'a2ui' && i.surfaceId === d.surface_id)
let item = index < 0 ? undefined : items[index] as ItemOf<'a2ui'>
if (item) {
item = { ...item }
items[index] = item
}
if (!item) {
item = { id: nid(), kind: 'a2ui', surfaceId: d.surface_id, component: {}, data: {} }
items.push(item)
Expand Down Expand Up @@ -391,8 +396,9 @@ function fold(items: ThreadItem[], type: HandledEvent, data: Record<string, unkn
}
case 'A2UISurfaceDataUpdated': {
const d = EventData.A2UISurfaceDataUpdated.parse(data)
const item = items.find((i): i is ItemOf<'a2ui'> => i.kind === 'a2ui' && i.surfaceId === d.surface_id)
if (item) item.data = d.data ?? {}
const index = items.findIndex((i) => i.kind === 'a2ui' && i.surfaceId === d.surface_id)
const item = items[index]
if (item?.kind === 'a2ui') items[index] = { ...item, data: d.data ?? {} }
break
}
case 'A2UIActionSubmitted': {
Expand Down
Loading