diff --git a/apps/desktop/e2e/agent-graph.spec.ts b/apps/desktop/e2e/agent-graph.spec.ts new file mode 100644 index 0000000000..fd44fec569 --- /dev/null +++ b/apps/desktop/e2e/agent-graph.spec.ts @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Locator } from '@playwright/test'; +import { expect, test } from './fixtures'; + +async function expectInsideViewport(target: Locator, viewport: Locator): Promise { + await expect + .poll(async () => { + const targetBox = await target.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + }); + const viewportBox = await viewport.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left + element.clientLeft, + top: rect.top + element.clientTop, + right: rect.left + element.clientLeft + element.clientWidth, + bottom: rect.top + element.clientTop + element.clientHeight, + }; + }); + const tolerance = 1; + return Boolean( + targetBox.left >= viewportBox.left - tolerance && + targetBox.top >= viewportBox.top - tolerance && + targetBox.right <= viewportBox.right + tolerance && + targetBox.bottom <= viewportBox.bottom + tolerance, + ); + }) + .toBe(true); +} + +function metadataValue(scope: Locator, label: string): Locator { + return scope + .locator('dt') + .filter({ hasText: label }) + .locator('xpath=following-sibling::dd[1]'); +} + +async function expectMetadata( + scope: Locator, + label: string, + value: string | RegExp, +): Promise { + await expect(metadataValue(scope, label)).toHaveText(value); +} + +test('inspects and follows an operator across graph views', async ({ + agentGraphWindow: page, +}) => { + const panel = page.getByRole('region', { name: 'Agent Graph', exact: true }); + const topology = page.getByTestId('agent-graph-topology'); + + await expect + .poll(() => + topology.evaluate((element) => ({ + horizontal: element.scrollWidth > element.clientWidth, + vertical: element.scrollHeight > element.clientHeight, + })), + ) + .toEqual({ horizontal: true, vertical: true }); + + const initialPublisherNode = topology.getByRole('button', { name: /^publisher\./u }); + await initialPublisherNode.click(); + await expect(initialPublisherNode).toBeFocused(); + await expectInsideViewport(initialPublisherNode, topology); + await expectInsideViewport(initialPublisherNode, panel); + await expect(page.getByRole('region', { name: 'Operator details: publisher' })).toBeAttached(); + await initialPublisherNode.click(); + + await panel.getByRole('radio', { name: 'List' }).click(); + const publisherRow = panel + .getByTestId('agent-graph-list') + .locator(':scope > li') + .filter({ has: page.getByText('publisher', { exact: true }) }); + await expect(publisherRow.getByText('Completed', { exact: true })).toBeVisible(); + await expect(publisherRow).toContainText('1 more work item omitted'); + + const detailsButton = publisherRow.getByRole('button', { + name: 'View publisher details', + }); + await detailsButton.click(); + await expect(detailsButton).toBeFocused(); + await expect(detailsButton).toHaveAttribute('aria-expanded', 'true'); + const details = page.getByRole('region', { name: 'Operator details: publisher' }); + await expect(details).toHaveAttribute('aria-busy', 'false'); + const collection = (name: string) => + details + .locator('.maka-agent-graph-details-collection') + .filter({ has: page.getByText(name, { exact: true }) }); + const publisherSessionId = /^\["[a-f0-9]{64}","child-publisher"\]$/u; + const activations = collection('Activations'); + const activation = activations.locator('li'); + await expect( + metadataValue(activation, 'firstEventTime').locator( + 'time[datetime="2026-05-22T02:59:57.000Z"]', + ), + ).toBeVisible(); + await expect( + metadataValue(activation, 'lastEventTime').locator( + 'time[datetime="2026-05-22T02:59:59.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(activation, 'lastRecordId', 'record-publisher-terminal'); + await expectMetadata(activation, 'terminalRecordId', 'record-publisher-terminal'); + await expectMetadata(activation, 'run.sessionId', publisherSessionId); + await expectMetadata(activation, 'run.agentRunId', 'run-publisher'); + await expectMetadata(activation, 'run.turnId', 'turn-publisher'); + + const claims = collection('Claims'); + const claim = claims.locator('li').filter({ hasText: 'claim-publisher' }); + await expectMetadata(claim, 'intentId', 'intent-publisher'); + await expectMetadata(claim, 'childSessionId', publisherSessionId); + await expect( + metadataValue(claim, 'claimedAt').locator('time[datetime="2026-05-22T02:59:56.000Z"]'), + ).toBeVisible(); + await expectMetadata(claim, 'run.sessionId', publisherSessionId); + await expectMetadata(claim, 'run.agentRunId', 'run-publisher'); + await expectMetadata(claim, 'run.turnId', 'turn-publisher'); + + const activity = collection('Recent activity'); + const permissionRecord = activity.locator('li').filter({ hasText: 'record-publisher-permission' }); + await expectMetadata(permissionRecord, 'activationId', 'activation-publisher'); + await expectMetadata(permissionRecord, 'signals', 'attention: permission request'); + await expect( + metadataValue(permissionRecord, 'eventTime').locator( + 'time[datetime="2026-05-22T02:59:57.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(permissionRecord, 'run.sessionId', publisherSessionId); + await expectMetadata(permissionRecord, 'run.agentRunId', 'run-publisher'); + await expectMetadata(permissionRecord, 'run.turnId', 'turn-publisher'); + const terminalRecord = activity.locator('li').filter({ hasText: 'record-publisher-terminal' }); + await expectMetadata(terminalRecord, 'activationId', 'activation-publisher'); + await expectMetadata(terminalRecord, 'signals', 'terminal: completed'); + await expect( + metadataValue(terminalRecord, 'eventTime').locator( + 'time[datetime="2026-05-22T02:59:59.000Z"]', + ), + ).toBeVisible(); + await expectMetadata(terminalRecord, 'run.sessionId', publisherSessionId); + await expectMetadata(terminalRecord, 'run.agentRunId', 'run-publisher'); + await expectMetadata(terminalRecord, 'run.turnId', 'turn-publisher'); + await expectInsideViewport(detailsButton, panel); + await expectInsideViewport(details.locator('.maka-agent-graph-details-heading'), panel); + + await panel.getByRole('radio', { name: 'Topology' }).click(); + const selectedNode = topology.locator('.maka-agent-graph-node[data-selected="true"]'); + await expect(selectedNode).toContainText('publisher'); + await expect(selectedNode).toContainText('1 more work item omitted'); + await expectInsideViewport(selectedNode, topology); + await expectInsideViewport(selectedNode, panel); + await expect.poll(() => topology.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + + await panel.getByRole('button', { name: 'Collapse Agent Graph' }).click(); + await panel.getByRole('button', { name: 'Expand Agent Graph' }).click(); + await expectInsideViewport(selectedNode, topology); + await expectInsideViewport(selectedNode, panel); + + await panel.getByRole('radio', { name: 'List' }).click(); + await expectInsideViewport(detailsButton, panel); + await expectInsideViewport(details.locator('.maka-agent-graph-details-heading'), panel); +}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 2a725c5029..0e5bf9576e 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -496,6 +496,7 @@ export const test = base.extend<{ projectSidebarWindow: Page; parentRemovalWindow: Page; railRenderWindow: Page; + agentGraphWindow: Page; promptRailWindow: Page; partialHistoryWindow: Page; promptRailMotionWindow: Page; @@ -590,6 +591,15 @@ export const test = base.extend<{ use, ); }, + agentGraphWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-testid="agent-graph-topology"]', + e2eFixtureScenario: 'agent-graph-topology', + locale: 'en', + showWindow: true, + }, use); + }, // A multi-prompt transcript for the prompt anchor rail. Shown, because every // assertion in prompt-rail.spec.ts is geometry the compositor has to settle. promptRailWindow: async ({}, use) => { diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts index 18b1690317..723ae5d7c2 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel.test.ts @@ -116,6 +116,8 @@ function installGraphRenderer( setCurrentWithoutNotification(next: AgentGraphClientSnapshot): void; notify(): void; epochReadCounts(): { full: number; current: number }; + inspectionReadCount(operatorId: string): number; + scrollIntoViewTargets(): readonly string[]; stopCalls: Array<{ sessionId: string; expectedGraphId: string }>; } { const { document, window } = parseHTML('
'); @@ -140,6 +142,12 @@ function installGraphRenderer( cancelAnimationFrame: (handle: number) => clearTimeout(handle), IS_REACT_ACT_ENVIRONMENT: true, }); + const scrollIntoViewTargets: string[] = []; + window.HTMLElement.prototype.scrollIntoView = function () { + scrollIntoViewTargets.push( + `${this.getAttribute('class') ?? this.tagName}:${this.textContent ?? ''}`, + ); + }; const snapshots = new Map( [initial, ...historical].map((entry) => [entry.graphId, entry] as const), @@ -157,6 +165,7 @@ function installGraphRenderer( const stopCalls: Array<{ sessionId: string; expectedGraphId: string }> = []; let fullEpochReads = 0; let currentEpochReads = 0; + const inspectionReads = new Map(); const epochDirectory = (sessionId: string) => { const currentGraphId = currentGraphIds.get(sessionId); const entries = [...snapshots.values()] @@ -205,8 +214,45 @@ function installGraphRenderer( } return next; }, - inspectOperator: async () => { - throw new Error('inspectOperator is unused by AgentGraphPanel'); + inspectOperator: async (sessionId: string, operatorId: string, graphId?: string) => { + inspectionReads.set(operatorId, (inspectionReads.get(operatorId) ?? 0) + 1); + const snapshot = graphId ? snapshots.get(graphId) : undefined; + const operator = snapshot?.operators.find((candidate) => candidate.operatorId === operatorId); + if (!snapshot || snapshot.rootSessionId !== sessionId || !operator) { + throw new Error(`missing graph operator ${operatorId}`); + } + return { + schemaVersion: 1, + rootSessionId: sessionId, + graphId: snapshot.graphId, + snapshotVersion: snapshot.snapshotVersion, + operator, + inboundEdges: snapshot.edges.filter((edge) => edge.toOperatorId === operatorId), + outboundEdges: snapshot.edges.filter((edge) => edge.fromOperatorId === operatorId), + work: snapshot.work.filter((work) => operator.scheduledWorkIds.includes(work.workId)), + claims: snapshot.claims.filter((claim) => claim.operatorId === operatorId), + activations: operator.currentActivation + ? [ + { + ...operator.currentActivation, + lastRecordId: + operator.currentActivation.terminalRecordId ?? + `record-${operator.currentActivation.activationId}`, + }, + ] + : [], + recentRecords: snapshot.recentActivity.filter( + (record) => record.operatorId === operatorId, + ), + omitted: { + inboundEdges: operator.omitted.inboundEdgeIds, + outboundEdges: operator.omitted.outboundEdgeIds, + work: operator.omitted.scheduledWorkIds, + claims: 0, + activations: 0, + records: 0, + }, + }; }, subscribe: (_sessionId: string, listener: GraphListener) => { listeners.add(listener); @@ -257,6 +303,12 @@ function installGraphRenderer( epochReadCounts() { return { full: fullEpochReads, current: currentEpochReads }; }, + inspectionReadCount(operatorId) { + return inspectionReads.get(operatorId) ?? 0; + }, + scrollIntoViewTargets() { + return [...scrollIntoViewTargets]; + }, async renderSession(sessionId) { await act(async () => { root.render( @@ -308,6 +360,236 @@ async function renderPanel( } describe('AgentGraphPanel dismiss', () => { + it('renders dependency topology, inspects a node, and keeps the list alternative', async () => { + const graph = snapshot({ + graphId: 'graph-topology', + status: 'active', + operators: [ + { + ...graphOperator('a', ['work-a', 'work-snapshot-omitted']), + currentActivation: { + activationId: 'activation-a', + status: 'running' as const, + recordCount: 2, + firstEventTime: 1, + lastEventTime: 2, + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + }, + omitted: { + ...graphOperator('a', ['work-a', 'work-snapshot-omitted']).omitted, + scheduledWorkIds: 2, + }, + }, + { + ...graphOperator('b', ['work-b']), + status: 'waiting', + readiness: [ + { + readinessId: 'wait-b', + status: 'waiting', + waitingFor: [ + { kind: 'input_route', upstreamOperatorIds: ['a'] }, + { kind: 'activation_missing', operatorId: 'c', activationId: 'activation-c' }, + ], + omittedWaitingFor: 2, + }, + ], + omitted: { + ...graphOperator('b', ['work-b']).omitted, + readiness: 1, + readinessWaits: 2, + }, + }, + ], + edges: [{ edgeId: 'edge-a-b', fromOperatorId: 'a', toOperatorId: 'b' }], + work: [ + graphWork('work-a', 'a', 'Collect inputs'), + graphWork('work-b', 'b', 'Synthesize result'), + ], + claims: [ + { + claimId: 'claim-a', + intentId: 'intent-a', + operatorId: 'a', + childSessionId: 'session-a', + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + admissionState: 'executing', + claimedAt: 1, + }, + ], + recentActivity: [ + { + recordId: 'record-a', + operatorId: 'a', + activationId: 'activation-a', + eventTime: 2, + facets: ['tool_call'], + signals: [{ kind: 'attention', reason: 'permission_request' }], + run: { sessionId: 'session-a', agentRunId: 'run-a', turnId: 'turn-a' }, + }, + ], + }); + const harness = await renderPanel(graph); + + assert.ok(harness.container.querySelector('[data-testid="agent-graph-topology"]')); + assert.equal(harness.container.querySelectorAll('.maka-agent-graph-edge').length, 1); + const node = harness.container.querySelector('.maka-agent-graph-node'); + assert.ok(node); + assert.equal(node.tagName, 'BUTTON'); + assert.equal(node.children[0]?.getAttribute('class'), 'maka-agent-graph-node-heading'); + assert.equal(node.querySelector('[role="img"]')?.getAttribute('aria-hidden'), 'true'); + assert.match(node.textContent ?? '', /Running/); + assert.equal(node.getAttribute('aria-pressed'), 'false'); + assert.match(node.getAttribute('aria-label') ?? '', /agent-a\. Running\. Collect inputs/); + assert.match(node.getAttribute('aria-label') ?? '', /3 more work items omitted/); + const waitingNode = harness.container.querySelectorAll('.maka-agent-graph-node')[1]; + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /Waiting for input from a/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /1 more wait/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /2 waits omitted/); + assert.match(waitingNode?.getAttribute('aria-label') ?? '', /1 readiness check omitted/); + assert.match(waitingNode?.textContent ?? '', /Waiting for input from a/); + await act(async () => { + (node as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(node.getAttribute('aria-pressed'), 'true'); + assert.ok(harness.container.querySelector('[aria-label="Operator details: agent-a"]')); + assert.match(harness.container.textContent ?? '', /0 inbound · 1 outbound/); + assert.match(harness.container.textContent ?? '', /Work/); + assert.match(harness.container.textContent ?? '', /Dependencies/); + assert.match(harness.container.textContent ?? '', /To agent-b/); + assert.match(harness.container.textContent ?? '', /activation-a/); + assert.match(harness.container.textContent ?? '', /firstEventTime/); + assert.match(harness.container.textContent ?? '', /lastRecordId/); + assert.match(harness.container.textContent ?? '', /claim-a/); + assert.match(harness.container.textContent ?? '', /intent-a/); + assert.match(harness.container.textContent ?? '', /run\.agentRunIdrun-a/); + assert.match(harness.container.textContent ?? '', /run\.turnIdturn-a/); + assert.match(harness.container.textContent ?? '', /tool call · record-a/); + assert.match(harness.container.textContent ?? '', /attention: permission request/); + const eventTime = harness.container.querySelector('time'); + assert.equal( + eventTime?.getAttribute('datetime') ?? eventTime?.getAttribute('dateTime'), + '1970-01-01T00:00:00.001Z', + ); + assert.match(harness.container.textContent ?? '', /2 more omitted/); + assert.equal(harness.inspectionReadCount('a'), 1); + const viewport = harness.container.querySelector('.maka-agent-graph-topology-viewport') as + | (HTMLElement & { scrollLeft: number }) + | null; + assert.ok(viewport); + Object.defineProperty(viewport, 'clientWidth', { value: 240 }); + viewport.scrollLeft = 10; + + await harness.setSnapshot({ + ...graph, + snapshotVersion: '2', + operators: [graphOperator('upstream', []), ...graph.operators], + edges: [ + ...graph.edges, + { edgeId: 'edge-upstream-a', fromOperatorId: 'upstream', toOperatorId: 'a' }, + ], + }); + assert.equal(harness.inspectionReadCount('a'), 2); + assert.match(harness.container.textContent ?? '', /1 inbound · 1 outbound/); + assert.equal(viewport.scrollLeft, 268); + + await act(async () => { + (node as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(node.getAttribute('aria-pressed'), 'false'); + assert.equal(harness.container.querySelector('[aria-label="Operator details: agent-a"]'), null); + + const listButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'List', + ); + assert.ok(listButton); + await act(async () => (listButton as HTMLElement).click()); + assert.ok(harness.container.querySelector('[data-testid="agent-graph-list"]')); + assert.match(harness.container.textContent ?? '', /Feeds agent-b/); + assert.match(harness.container.textContent ?? '', /Depends on agent-a/); + const detailsButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.getAttribute('aria-label') === 'View agent-a details', + ); + assert.ok(detailsButton); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'false'); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'true'); + const details = harness.container.querySelector( + 'section[aria-label="Operator details: agent-a"]', + ); + assert.ok(details); + assert.equal(detailsButton.getAttribute('aria-controls'), details.id); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + assert.equal(detailsButton.getAttribute('aria-expanded'), 'false'); + assert.equal( + harness.container.querySelector('section[aria-label="Operator details: agent-a"]'), + null, + ); + await act(async () => harness.root.unmount()); + }); + + it('keeps keyboard order visual and reveals a retained selection', async () => { + const harness = await renderPanel( + snapshot({ + graphId: 'graph-selection', + status: 'active', + operators: [graphOperator('c', []), graphOperator('b', []), graphOperator('a', [])], + edges: [ + { edgeId: 'edge-a-b', fromOperatorId: 'a', toOperatorId: 'b' }, + { edgeId: 'edge-b-c', fromOperatorId: 'b', toOperatorId: 'c' }, + ], + }), + ); + assert.deepEqual( + [...harness.container.querySelectorAll('.maka-agent-graph-node strong')].map( + (node) => node.textContent, + ), + ['agent-a', 'agent-b', 'agent-c'], + ); + + const listButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'List', + ); + assert.ok(listButton); + await act(async () => (listButton as HTMLElement).click()); + const operatorRow = [...harness.container.querySelectorAll('.maka-agent-graph-operators li')].find( + (row) => row.querySelector('strong')?.textContent === 'agent-c', + ); + const detailsButton = [...(operatorRow?.querySelectorAll('button') ?? [])].find( + (button) => button.getAttribute('aria-label') === 'View agent-c details', + ); + assert.ok(detailsButton); + await act(async () => { + (detailsButton as HTMLElement).click(); + await Promise.resolve(); + }); + const detailsRevealCount = selectedDetailsRevealCount(harness); + + const topologyButton = [...harness.container.querySelectorAll('button')].find( + (button) => button.textContent === 'Topology', + ); + assert.ok(topologyButton); + await act(async () => (topologyButton as HTMLElement).click()); + assert.equal(selectedDetailsRevealCount(harness), detailsRevealCount); + + const collapseButton = harness.container.querySelector('[aria-label="Collapse Agent Graph"]'); + assert.ok(collapseButton); + await act(async () => (collapseButton as HTMLElement).click()); + const expandButton = harness.container.querySelector('[aria-label="Expand Agent Graph"]'); + assert.ok(expandButton); + await act(async () => (expandButton as HTMLElement).click()); + assert.equal(selectedDetailsRevealCount(harness), detailsRevealCount); + await act(async () => harness.root.unmount()); + }); + it('keeps the new session loading when a disposed read settles later', async () => { const sessionA = snapshot({ graphId: 'graph-a', status: 'active' }); const sessionB = snapshot({ @@ -696,3 +978,46 @@ describe('AgentGraphPanel dismiss', () => { await act(async () => harness.root.unmount()); }); }); + +function graphOperator(operatorId: string, scheduledWorkIds: string[]) { + return { + operatorId, + childSessionId: `session-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: `agent-${operatorId}`, + provisionedAt: 1, + status: 'running' as const, + inboundEdgeIds: [], + outboundEdgeIds: [], + scheduledWorkIds, + readiness: [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + }; +} + +function graphWork(workId: string, operatorId: string, instructionPreview: string) { + return { + workId, + target: { kind: 'operator' as const, operatorId }, + inputIds: [], + status: 'requested' as const, + instructionPreview, + instructionTruncated: false, + revision: 1, + committedAt: 1, + }; +} + +function selectedDetailsRevealCount( + harness: Pick, 'scrollIntoViewTargets'>, +): number { + return harness + .scrollIntoViewTargets() + .filter((target) => target.startsWith('maka-agent-graph-details-heading:')).length; +} diff --git a/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts b/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts new file mode 100644 index 0000000000..d72b45b665 --- /dev/null +++ b/apps/desktop/src/main/__tests__/agent-graph-topology.test.ts @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { + AgentGraphClientEdge, + AgentGraphClientOperator, +} from '@maka/runtime/stream-graph-read-model'; +import { + agentGraphEdgePath, + agentGraphStatusSemantic, + firstScheduledWorkPreview, + layoutAgentGraph, + revealAgentGraphNode, + scheduledWorkPresentation, +} from '../../renderer/agent-graph-topology.js'; + +describe('layoutAgentGraph', () => { + it('preserves graph operator status semantics', () => { + assert.equal(agentGraphStatusSemantic('running'), 'active'); + assert.equal(agentGraphStatusSemantic('blocked'), 'attention'); + assert.equal(agentGraphStatusSemantic('completed'), 'success'); + assert.equal(agentGraphStatusSemantic('failed'), 'error'); + }); + + it('uses the bounded preview without duplicating its truncation marker', () => { + const preview = firstScheduledWorkPreview(operator('a', ['work-a']), [ + { + workId: 'work-a', + target: { kind: 'operator', operatorId: 'a' }, + inputIds: [], + status: 'requested', + instructionPreview: 'Collect inputs…', + instructionTruncated: true, + revision: 1, + committedAt: 1, + }, + ]); + + assert.equal(preview, 'Collect inputs…'); + }); + + it('prefers the newest requested work and does not substitute an operator id', () => { + const selected = firstScheduledWorkPreview(operator('a', ['old', 'stopped', 'new']), [ + work('old', 'requested', 1, 'Old request'), + work('stopped', 'stopped', 3, 'Stopped request'), + work('new', 'requested', 2, 'New request'), + ]); + + assert.equal(selected, 'New request'); + assert.equal(firstScheduledWorkPreview(operator('b'), []), undefined); + }); + + it('counts operator work omitted by both operator and snapshot bounds', () => { + const boundedOperator = operator('a', ['visible', 'snapshot-omitted']); + boundedOperator.omitted.scheduledWorkIds = 2; + + assert.deepEqual( + scheduledWorkPresentation(boundedOperator, [ + work('visible', 'requested', 1, 'Visible request'), + ]), + { preview: 'Visible request', omitted: 3 }, + ); + }); + + it('places a dependency chain in successive columns', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c')], + ); + const positions = positionMap(layout.nodes); + + assert.ok(positions.a.x < positions.b.x); + assert.ok(positions.b.x < positions.c.x); + assert.equal(positions.a.y, positions.b.y); + assert.equal(positions.b.y, positions.c.y); + }); + + it('keeps fan-out peers together and places their join downstream', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c'), operator('d')], + [edge('a-b', 'a', 'b'), edge('a-c', 'a', 'c'), edge('b-d', 'b', 'd'), edge('c-d', 'c', 'd')], + ); + const positions = positionMap(layout.nodes); + + assert.equal(positions.b.x, positions.c.x); + assert.notEqual(positions.b.y, positions.c.y); + assert.ok(positions.a.x < positions.b.x); + assert.ok(positions.b.x < positions.d.x); + }); + + it('keeps depth propagated from visited ancestors when edges form a cycle', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c'), edge('c-b', 'c', 'b')], + ); + const positions = positionMap(layout.nodes); + + assert.ok(positions.a.x < positions.b.x); + }); + + it('ignores omitted edge endpoints without moving visible nodes', () => { + const operators = [operator('a'), operator('b')]; + const complete = layoutAgentGraph(operators, [edge('a-b', 'a', 'b')]); + const partial = layoutAgentGraph(operators, [ + edge('a-b', 'a', 'b'), + edge('missing-a', 'missing', 'a'), + edge('b-missing', 'b', 'missing'), + ]); + + assert.deepEqual(partial, complete); + }); + + it('keeps rows stable when the read model reorders operators by lifecycle state', () => { + const original = [operator('a', [], 1), operator('b', [], 2), operator('c', [], 3)]; + const reordered = [ + { ...original[2]!, status: 'running' as const }, + { ...original[0]!, status: 'completed' as const }, + { ...original[1]!, status: 'waiting' as const }, + ]; + + assert.deepEqual( + positionMap(layoutAgentGraph(original, []).nodes), + positionMap(layoutAgentGraph(reordered, []).nodes), + ); + }); + + it('routes skip-level edges through the gap above intervening nodes', () => { + const layout = layoutAgentGraph( + [operator('a'), operator('b'), operator('c'), operator('d')], + [edge('a-b', 'a', 'b'), edge('b-c', 'b', 'c'), edge('c-d', 'c', 'd')], + ); + const positions = positionMap(layout.nodes); + const path = agentGraphEdgePath(positions.a, positions.d); + + assert.doesNotMatch(path, / C /u); + assert.match(path, / L \d+ 8 L \d+ 8 /u); + assert.ok(8 < positions.b.y); + assert.ok(8 < positions.c.y); + }); + + it('reveals a node without scrolling an ancestor', () => { + const viewport = { + clientHeight: 168, + clientWidth: 240, + scrollLeft: 10, + scrollTop: 20, + }; + + revealAgentGraphNode(viewport, { operatorId: 'a', x: 300, y: 220 }); + + assert.deepEqual(viewport, { + clientHeight: 168, + clientWidth: 240, + scrollLeft: 268, + scrollTop: 156, + }); + }); +}); + +function operator( + operatorId: string, + scheduledWorkIds: string[] = [], + provisionedAt = 1, +): AgentGraphClientOperator { + return { + operatorId, + childSessionId: `session-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: `agent-${operatorId}`, + provisionedAt, + status: 'not_started', + inboundEdgeIds: [], + outboundEdgeIds: [], + scheduledWorkIds, + readiness: [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + }; +} + +function work( + workId: string, + status: 'requested' | 'stopped' | 'superseded', + revision: number, + instructionPreview: string, +) { + return { + workId, + target: { kind: 'operator' as const, operatorId: 'a' }, + inputIds: [], + status, + instructionPreview, + instructionTruncated: false, + revision, + committedAt: revision, + }; +} + +function edge(edgeId: string, fromOperatorId: string, toOperatorId: string): AgentGraphClientEdge { + return { edgeId, fromOperatorId, toOperatorId }; +} + +function positionMap(nodes: readonly { operatorId: string; x: number; y: number }[]) { + return Object.fromEntries(nodes.map((node) => [node.operatorId, node])) as Record< + string, + { operatorId: string; x: number; y: number } + >; +} diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index dff4e6aab5..c9c02a016a 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -47,6 +47,10 @@ import { turnMessages, turnSession, } from './e2e-fixture/scenarios-chat.js'; +import { + agentGraphSession, + seedAgentGraphTopology, +} from './e2e-fixture/scenarios-agent-graph.js'; import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios-modules.js'; import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; import { @@ -72,6 +76,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'module-mcp', 'module-daily-review', 'scheduled-tasks', + 'agent-graph-topology', 'sidebar-search-modal-open', ]); @@ -203,6 +208,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'daily-review', sidebarCollapsed: false }; case 'scheduled-tasks': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'automations', sidebarCollapsed: false }; + case 'agent-graph-topology': + return { ...state, activeSessionId: TURN_SESSION_ID, workbarCollapsed: true }; case 'sidebar-search-modal-open': return { ...state, @@ -226,7 +233,15 @@ export async function seedE2eFixture(input: { const storageRoot = await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); await writeSettings(input.workspaceRoot, scenario); await writeConnections(input.workspaceRoot, now, scenario); - await writeSession(input.workspaceRoot, turnSession(now), turnMessages(now)); + await writeSession( + input.workspaceRoot, + scenario === 'agent-graph-topology' ? agentGraphSession(now) : turnSession(now), + turnMessages(now), + ); + + if (scenario === 'agent-graph-topology') { + await seedAgentGraphTopology(input.workspaceRoot, now); + } if (scenario === 'chat-prompt-rail') { await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts b/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts new file mode 100644 index 0000000000..dc5f5ab60d --- /dev/null +++ b/apps/desktop/src/main/e2e-fixture/scenarios-agent-graph.ts @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionHeader } from '@maka/core/session'; +import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; +import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; +import type { + AgentGraphClientActivity, + AgentGraphClientClaimRef, + AgentGraphClientOperator, + AgentGraphClientScheduledWork, + AgentGraphClientSnapshot, + AgentGraphOperatorInspection, +} from '@maka/runtime/stream-graph-read-model'; +import { turnSession } from './scenarios-chat.js'; +import { TURN_SESSION_ID } from './seed-helpers.js'; + +const OPERATOR_IDS = [ + 'collector', + 'planner', + 'researcher', + 'reviewer', + 'writer', + 'publisher', +] as const; +const EDGE_PAIRS = [ + ['collector', 'planner'], + ['collector', 'researcher'], + ['collector', 'reviewer'], + ['researcher', 'writer'], + ['writer', 'publisher'], +] as const; +const SNAPSHOT_VERSION = `sha256:${'a'.repeat(64)}`; +const TOPOLOGY_FINGERPRINT = `sha256:${'b'.repeat(64)}`; + +export function agentGraphSession(now: number): SessionHeader { + return { + ...turnSession(now), + name: 'Agent Graph topology fixture', + orchestrationMode: 'graph', + }; +} + +export async function seedAgentGraphTopology(workspaceRoot: string, now: number): Promise { + const graphId = agentGraphIdForRootSession(TURN_SESSION_ID); + const operators = OPERATOR_IDS.map((operatorId, index) => + fixtureOperator(operatorId, index, now), + ); + const visibleWork = fixtureWork('work-publisher-visible', 'publisher', 'Publish the final report', now); + const omittedWork = fixtureWork( + 'work-publisher-snapshot-omitted', + 'publisher', + 'Notify downstream consumers', + now - 1, + ); + const edges = EDGE_PAIRS.map(([fromOperatorId, toOperatorId]) => ({ + edgeId: `edge-${fromOperatorId}-${toOperatorId}`, + fromOperatorId, + toOperatorId, + })); + const permissionActivity: AgentGraphClientActivity = { + recordId: 'record-publisher-permission', + operatorId: 'publisher', + activationId: 'activation-publisher', + eventTime: now - 3_000, + facets: ['permission_request'], + signals: [{ kind: 'attention', reason: 'permission_request' }], + run: { sessionId: 'child-publisher', agentRunId: 'run-publisher', turnId: 'turn-publisher' }, + }; + const terminalActivity: AgentGraphClientActivity = { + ...permissionActivity, + recordId: 'record-publisher-terminal', + eventTime: now - 1_000, + facets: ['completed'], + signals: [{ kind: 'terminal', status: 'completed' }], + }; + const claim: AgentGraphClientClaimRef = { + claimId: 'claim-publisher', + intentId: 'intent-publisher', + operatorId: 'publisher', + childSessionId: 'child-publisher', + run: terminalActivity.run, + admissionState: 'executing', + claimedAt: now - 4_000, + }; + const snapshot: AgentGraphClientSnapshot = { + schemaVersion: 1, + rootSessionId: TURN_SESSION_ID, + graphId, + orchestrationMode: 'graph', + snapshotVersion: SNAPSHOT_VERSION, + status: 'active', + scheduleRevision: 1, + topologyFingerprint: TOPOLOGY_FINGERPRINT, + closed: false, + latestEventTime: terminalActivity.eventTime, + operators, + edges, + work: [visibleWork], + reconciliationFailures: [], + stoppedTargets: [], + claims: [claim], + recentControlDecisions: [], + recentActivity: [permissionActivity, terminalActivity], + terminalHistory: { records: [terminalActivity] }, + omitted: { + operators: 0, + edges: 0, + work: 1, + reconciliationFailures: 0, + stoppedTargets: 0, + claims: 0, + controlDecisions: 0, + recentActivity: 0, + }, + }; + const inspections = operators.map((operator) => + fixtureInspection(snapshot, operator, [visibleWork, omittedWork]), + ); + const store = createAgentGraphControlStore(workspaceRoot); + try { + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId, + rootSessionId: TURN_SESSION_ID, + expectedSnapshotVersion: null, + snapshotVersion: SNAPSHOT_VERSION, + snapshot, + replaceOperators: true, + operators: inspections.map((inspection) => ({ + operatorId: inspection.operator.operatorId, + payload: inspection, + })), + terminalActivities: [ + { + recordId: terminalActivity.recordId, + eventTime: terminalActivity.eventTime, + payload: terminalActivity, + }, + ], + activityRecords: [permissionActivity, terminalActivity].map((activity) => ({ + recordId: activity.recordId, + eventTime: activity.eventTime, + })), + }); + } finally { + store.close(); + } +} + +function fixtureOperator( + operatorId: (typeof OPERATOR_IDS)[number], + index: number, + now: number, +): AgentGraphClientOperator { + const publisher = operatorId === 'publisher'; + return { + operatorId, + childSessionId: `child-${operatorId}`, + provisionId: `provision-${operatorId}`, + agentId: operatorId, + provisionedAt: now - (OPERATOR_IDS.length - index + 4) * 1_000, + status: publisher ? 'completed' : operatorId === 'writer' ? 'waiting' : 'running', + inboundEdgeIds: EDGE_PAIRS.filter(([, target]) => target === operatorId).map( + ([source, target]) => `edge-${source}-${target}`, + ), + outboundEdgeIds: EDGE_PAIRS.filter(([source]) => source === operatorId).map( + ([source, target]) => `edge-${source}-${target}`, + ), + scheduledWorkIds: publisher + ? ['work-publisher-visible', 'work-publisher-snapshot-omitted'] + : [], + readiness: + operatorId === 'writer' + ? [ + { + readinessId: 'readiness-writer', + status: 'waiting', + waitingFor: [{ kind: 'input_route', upstreamOperatorIds: ['researcher'] }], + omittedWaitingFor: 0, + }, + ] + : [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + ...(publisher + ? { + currentActivation: { + activationId: 'activation-publisher', + status: 'completed' as const, + recordCount: 2, + firstEventTime: now - 3_000, + lastEventTime: now - 1_000, + terminalRecordId: 'record-publisher-terminal', + run: { + sessionId: 'child-publisher', + agentRunId: 'run-publisher', + turnId: 'turn-publisher', + }, + }, + } + : {}), + }; +} + +function fixtureWork( + workId: string, + operatorId: string, + instructionPreview: string, + committedAt: number, +): AgentGraphClientScheduledWork { + return { + workId, + target: { kind: 'operator', operatorId }, + inputIds: [], + status: 'requested', + instructionPreview, + instructionTruncated: false, + revision: 1, + committedAt, + }; +} + +function fixtureInspection( + snapshot: AgentGraphClientSnapshot, + operator: AgentGraphClientOperator, + publisherWork: readonly AgentGraphClientScheduledWork[], +): AgentGraphOperatorInspection { + const publisher = operator.operatorId === 'publisher'; + return { + schemaVersion: 1, + rootSessionId: snapshot.rootSessionId, + graphId: snapshot.graphId, + snapshotVersion: snapshot.snapshotVersion, + operator, + inboundEdges: snapshot.edges.filter((edge) => edge.toOperatorId === operator.operatorId), + outboundEdges: snapshot.edges.filter((edge) => edge.fromOperatorId === operator.operatorId), + work: publisher ? [...publisherWork] : [], + claims: publisher ? snapshot.claims : [], + activations: + publisher && operator.currentActivation + ? [ + { + ...operator.currentActivation, + lastRecordId: 'record-publisher-terminal', + }, + ] + : [], + recentRecords: publisher ? snapshot.recentActivity : [], + omitted: { + inboundEdges: 0, + outboundEdges: 0, + work: 0, + claims: 0, + activations: 0, + records: 0, + }, + }; +} diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index 32e8a38810..91cca67358 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -17,17 +17,30 @@ * under the License. */ -import { useEffect, useId, useMemo, useRef, useState, type JSX } from 'react'; +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type JSX, + type ReactNode, + type RefObject, +} from 'react'; import type { UiLocale } from '@maka/core/ui-locale'; import type { AgentGraphClientOperator, + AgentGraphClientRunRef, AgentGraphClientSnapshot, + AgentGraphOperatorInspection, } from '@maka/runtime/stream-graph-read-model'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; import { IconButton, Selector, type SelectorOptionType } from '@maka/ui'; import { ICON_SIZE, ChevronDown, X } from '@maka/ui/icons'; import { Banner } from '@astryxdesign/core/Banner'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { Button } from '@astryxdesign/core/Button'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Spinner } from '@astryxdesign/core/Spinner'; @@ -42,6 +55,12 @@ import { createAgentGraphRefreshScheduler, type AgentGraphRefreshScheduler, } from './agent-graph-refresh.js'; +import { + AgentGraphTopology, + AgentGraphStatusDot, + firstScheduledWorkPreview, + scheduledWorkPresentation, +} from './agent-graph-topology.js'; const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { requestRefresh() {}, @@ -50,6 +69,22 @@ const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { dispose() {}, }; +const GRAPH_DATE_TIME_FORMATTERS: Record = { + en: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'medium' }), + zh: new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'medium' }), +}; + +function hasSameInspectionPresentation( + current: AgentGraphOperatorInspection | undefined, + next: AgentGraphOperatorInspection, +): boolean { + return ( + current?.graphId === next.graphId && + current.operator.operatorId === next.operator.operatorId && + current.snapshotVersion === next.snapshotVersion + ); +} + type GraphPanelCopy = { title: string; loading: string; @@ -63,13 +98,34 @@ type GraphPanelCopy = { loadFailed: string; openSession: string; operators: string; + topology: string; + list: string; + view: string; + details: string; + inspectOperator(name: string): string; + detailsLoading: string; + detailsFailed: string; + openSessionFor(name: string): string; + workOmitted(count: number): string; + workItems: string; + dependenciesDetails: string; + activations: string; + claims: string; + recentActivity: string; + fromOperator(name: string): string; + toOperator(name: string): string; + omittedItems(count: number): string; + records(count: number): string; + activationRecords(count: number): string; + edgeSummary(inbound: number, outbound: number): string; selectedResults: string; epoch: string; currentEpoch: string; historicalEpoch: string; cappedEpochs(count: number): string; noOperators: string; - hiddenOperators(count: number): string; + hiddenTopology(operators: number, edges: number, work: number): string; + dependencies(upstream: readonly string[], downstream: readonly string[]): string | undefined; progress(settled: number, total: number, hasOmitted: boolean): string; status(status: AgentGraphClientSnapshot['status']): string; operatorStatus(status: AgentGraphClientOperator['status']): string; @@ -91,13 +147,47 @@ export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { loadFailed: 'Graph 状态刷新失败。', openSession: '打开子任务', operators: 'Operators', + topology: '拓扑', + list: '列表', + view: 'Agent Graph 视图', + details: 'Operator 详情', + inspectOperator: (name) => `查看 ${name} 详情`, + detailsLoading: '正在读取 operator 详情…', + detailsFailed: '无法读取 operator 详情。', + openSessionFor: (name) => `打开 ${name} 子任务`, + workOmitted: (count) => `另有 ${count} 项 work 已省略`, + workItems: 'Work', + dependenciesDetails: '依赖关系', + activations: 'Activations', + claims: 'Claims', + recentActivity: '近期活动', + fromOperator: (name) => `来自 ${name}`, + toOperator: (name) => `前往 ${name}`, + omittedItems: (count) => `另有 ${count} 项已省略`, + records: (count) => `${count} 条记录`, + activationRecords: (count) => `${count} 条 activation 记录`, + edgeSummary: (inbound, outbound) => `${inbound} 条入边 · ${outbound} 条出边`, selectedResults: '已选择结果', epoch: 'Graph 运行轮次', currentEpoch: '当前', historicalEpoch: '历史记录(只读)', cappedEpochs: (count) => `仅显示最近 ${count} 次运行`, noOperators: '等待主 Agent 创建 operator…', - hiddenOperators: (count) => `另有 ${count} 个 operator`, + hiddenTopology: (operators, edges, work) => { + const parts = [ + ...(operators > 0 ? [`${operators} 个 operator`] : []), + ...(edges > 0 ? [`${edges} 条边`] : []), + ...(work > 0 ? [`${work} 项 work`] : []), + ]; + return `当前拓扑不完整:省略 ${parts.join('、')}`; + }, + dependencies: (upstream, downstream) => { + const parts = [ + ...(upstream.length > 0 ? [`依赖 ${upstream.join('、')}`] : []), + ...(downstream.length > 0 ? [`下游 ${downstream.join('、')}`] : []), + ]; + return parts.length > 0 ? parts.join(' · ') : undefined; + }, progress: (settled, total, hasOmitted) => hasOmitted ? `可见 ${settled}/${total} 已结束` : `${settled}/${total} 已结束`, status: (status) => @@ -138,13 +228,47 @@ export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { loadFailed: 'Could not refresh graph state.', openSession: 'Open child task', operators: 'Operators', + topology: 'Topology', + list: 'List', + view: 'Agent Graph view', + details: 'Operator details', + inspectOperator: (name) => `View ${name} details`, + detailsLoading: 'Loading operator details…', + detailsFailed: 'Could not load operator details.', + openSessionFor: (name) => `Open ${name} child task`, + workOmitted: (count) => `${count} more work item${count === 1 ? '' : 's'} omitted`, + workItems: 'Work', + dependenciesDetails: 'Dependencies', + activations: 'Activations', + claims: 'Claims', + recentActivity: 'Recent activity', + fromOperator: (name) => `From ${name}`, + toOperator: (name) => `To ${name}`, + omittedItems: (count) => `${count} more omitted`, + records: (count) => `${count} record${count === 1 ? '' : 's'}`, + activationRecords: (count) => `${count} activation record${count === 1 ? '' : 's'}`, + edgeSummary: (inbound, outbound) => `${inbound} inbound · ${outbound} outbound`, selectedResults: 'Selected results', epoch: 'Graph run', currentEpoch: 'Current', historicalEpoch: 'History (read-only)', cappedEpochs: (count) => `Showing the newest ${count} runs`, noOperators: 'Waiting for the main agent to create an operator…', - hiddenOperators: (count) => `${count} more operator${count === 1 ? '' : 's'}`, + hiddenTopology: (operators, edges, work) => { + const parts = [ + ...(operators > 0 ? [`${operators} operator${operators === 1 ? '' : 's'}`] : []), + ...(edges > 0 ? [`${edges} edge${edges === 1 ? '' : 's'}`] : []), + ...(work > 0 ? [`${work} work item${work === 1 ? '' : 's'}`] : []), + ]; + return `Partial topology: ${parts.join(', ')} omitted`; + }, + dependencies: (upstream, downstream) => { + const parts = [ + ...(upstream.length > 0 ? [`Depends on ${upstream.join(', ')}`] : []), + ...(downstream.length > 0 ? [`Feeds ${downstream.join(', ')}`] : []), + ]; + return parts.length > 0 ? parts.join(' · ') : undefined; + }, progress: (settled, total, hasOmitted) => hasOmitted ? `${settled}/${total} visible settled` : `${settled}/${total} settled`, status: (status) => @@ -193,12 +317,22 @@ export function AgentGraphPanel(props: { error: false, }); const [collapsed, setCollapsed] = useState(false); + const [view, setView] = useState<'topology' | 'list'>('topology'); + const [selectedOperatorId, setSelectedOperatorId] = useState(); + const [inspection, setInspection] = useState(); + const [inspectionState, setInspectionState] = useState<'idle' | 'loading' | 'error'>('idle'); const [dismissedBySession, setDismissedBySession] = useState({}); const contentId = useId(); + const detailsId = useId(); const refreshRef = useRef(noopAgentGraphRefreshScheduler); const selectedGraphIdRef = useRef(undefined); const followCurrentRef = useRef(true); const stopRequestIdRef = useRef(0); + const inspectionIdentityRef = useRef(undefined); + const previousDetailsPresentationRef = useRef< + { identity: string; view: 'topology' | 'list' } | undefined + >(undefined); + const detailsHeadingRef = useRef(null); const copy = getAgentGraphPanelCopy(props.locale); const stopFeedbackMatchesSelection = stopState.rootSessionId === props.rootSessionId && stopState.graphId === selectedGraphId; @@ -221,6 +355,10 @@ export function AgentGraphPanel(props: { error: false, }); setCollapsed(false); + setView('topology'); + setSelectedOperatorId(undefined); + setInspection(undefined); + setInspectionState('idle'); setLoading(props.enabled); let cachedDirectory: AgentGraphEpochDirectory | undefined; @@ -280,6 +418,37 @@ export function AgentGraphPanel(props: { }; }, [props.rootSessionId, props.enabled]); + useEffect(() => { + if (!snapshot || !selectedOperatorId) { + inspectionIdentityRef.current = undefined; + setInspection(undefined); + setInspectionState('idle'); + return; + } + const identity = `${props.rootSessionId}:${snapshot.graphId}:${selectedOperatorId}`; + if (inspectionIdentityRef.current !== identity) { + inspectionIdentityRef.current = identity; + setInspection(undefined); + } + setInspectionState('loading'); + let active = true; + void window.maka.graphs + .inspectOperator(props.rootSessionId, selectedOperatorId, snapshot.graphId) + .then((next) => { + if (!active) return; + setInspection((current) => (hasSameInspectionPresentation(current, next) ? current : next)); + setInspectionState('idle'); + }) + .catch(() => { + if (!active) return; + setInspection(undefined); + setInspectionState('error'); + }); + return () => { + active = false; + }; + }, [props.rootSessionId, selectedOperatorId, snapshot?.graphId, snapshot?.snapshotVersion]); + useEffect(() => { setDismissedBySession((current) => reconcileAgentGraphPanelDismissals( @@ -303,6 +472,51 @@ export function AgentGraphPanel(props: { return { settled, total: snapshot?.operators.length ?? 0 }; }, [snapshot]); const selectedEpoch = epochs.find((entry) => entry.graphId === selectedGraphId); + const selectedOperator = selectedOperatorId + ? snapshot?.operators.find((operator) => operator.operatorId === selectedOperatorId) + : undefined; + const selectedDetailsIdentity = + snapshot && selectedOperator + ? `${snapshot.graphId}:${selectedOperator.operatorId}` + : undefined; + const selectedInspection = + snapshot && + selectedOperator && + inspection?.operator.operatorId === selectedOperator.operatorId && + inspection.graphId === snapshot.graphId && + inspection.snapshotVersion === snapshot.snapshotVersion + ? inspection + : undefined; + useLayoutEffect(() => { + const previous = previousDetailsPresentationRef.current; + if ( + view === 'list' && + selectedDetailsIdentity && + (previous?.identity !== selectedDetailsIdentity || + previous.view !== 'list') + ) { + detailsHeadingRef.current?.scrollIntoView?.({ behavior: 'auto', block: 'nearest' }); + } + previousDetailsPresentationRef.current = selectedDetailsIdentity + ? { identity: selectedDetailsIdentity, view } + : undefined; + }, [selectedDetailsIdentity, view]); + const toggleOperator = (operatorId: string) => + setSelectedOperatorId((current) => (current === operatorId ? undefined : operatorId)); + const selectedDetails = + snapshot && selectedOperator && selectedDetailsIdentity ? ( + + ) : null; const hasGraphActivity = snapshot !== undefined && @@ -477,37 +691,100 @@ export function AgentGraphPanel(props: { title={copy.noOperators} /> ) : ( -
    + <> +
    + setView(value as 'topology' | 'list')} + > + + + +
    + {view === 'topology' ? ( + <> + + {selectedDetails} + + ) : ( +
      {snapshot.operators.map((operator) => { const wait = copy.wait(operator); - const work = snapshot.work.find((candidate) => - operator.scheduledWorkIds.includes(candidate.workId), + const work = scheduledWorkPresentation(operator, snapshot.work); + const visibleWork = work.preview + ? [work.preview, work.omitted > 0 ? copy.workOmitted(work.omitted) : undefined] + .filter(Boolean) + .join(' · ') + : work.omitted > 0 + ? copy.workOmitted(work.omitted) + : undefined; + const relations = copy.dependencies( + snapshot.edges + .filter((edge) => edge.toOperatorId === operator.operatorId) + .map((edge) => agentName(snapshot, edge.fromOperatorId)), + snapshot.edges + .filter((edge) => edge.fromOperatorId === operator.operatorId) + .map((edge) => agentName(snapshot, edge.toOperatorId)), ); return ( -
    • -
    • + - {operator.agentId} - {work?.instructionPreview ?? operator.operatorId} + + {operator.agentId} + + {copy.operatorStatus(operator.status)} + + + {visibleWork ? {visibleWork} : null} + {relations ? ( + {relations} + ) : null} {wait ? {wait} : null} - - {copy.operatorStatus(operator.status)} - + + {selectedOperatorId === operator.operatorId ? selectedDetails : null}
    • ); })}
    + )} + )} - {snapshot.omitted.operators > 0 ? ( + {snapshot.omitted.operators > 0 || snapshot.omitted.edges > 0 || snapshot.omitted.work > 0 ? (
    - {copy.hiddenOperators(snapshot.omitted.operators)} + {copy.hiddenTopology(snapshot.omitted.operators, snapshot.omitted.edges, snapshot.omitted.work)}
    ) : null} {snapshot.finish ? ( @@ -524,6 +801,217 @@ export function AgentGraphPanel(props: { ); } +function AgentGraphOperatorDetails(props: { + id: string; + operator: AgentGraphClientOperator; + snapshot: AgentGraphClientSnapshot; + inspection: AgentGraphOperatorInspection | undefined; + state: 'idle' | 'loading' | 'error'; + copy: GraphPanelCopy; + locale: UiLocale; + headingRef: RefObject; + onOpenSession(sessionId: string): void; +}) { + const operator = props.operator; + const wait = props.copy.wait(operator); + const inspection = props.inspection; + const snapshotWork = firstScheduledWorkPreview(operator, props.snapshot.work); + return ( +
    +
    + {operator.agentId} + {props.copy.operatorStatus(operator.status)} +
    + {!inspection && snapshotWork ?

    {snapshotWork}

    : null} + {wait ?

    {wait}

    : null} + {props.state === 'loading' && !props.inspection ? {props.copy.detailsLoading} : null} + {props.state === 'error' ? {props.copy.detailsFailed} : null} + {props.state !== 'error' && inspection ? ( + <> + + {props.copy.edgeSummary( + inspection.inboundEdges.length + inspection.omitted.inboundEdges, + inspection.outboundEdges.length + inspection.omitted.outboundEdges, + )} ·{' '} + {props.copy.activationRecords( + inspection.activations.length + inspection.omitted.activations, + )} + + ({ + key: entry.workId, + text: `${humanizeGraphValue(entry.status)} · ${entry.instructionPreview}`, + }))} + omitted={inspection.omitted.work} + omittedItems={props.copy.omittedItems} + /> + ({ + key: edge.edgeId, + text: props.copy.fromOperator(agentName(props.snapshot, edge.fromOperatorId)), + })), + ...inspection.outboundEdges.map((edge) => ({ + key: edge.edgeId, + text: props.copy.toOperator(agentName(props.snapshot, edge.toOperatorId)), + })), + ]} + omitted={inspection.omitted.inboundEdges + inspection.omitted.outboundEdges} + omittedItems={props.copy.omittedItems} + /> + ({ + key: activation.activationId, + text: `${humanizeGraphValue(activation.status)} · ${props.copy.records(activation.recordCount)} · ${activation.activationId}`, + metadata: [ + graphTimeMetadata('firstEventTime', activation.firstEventTime, props.locale), + graphTimeMetadata('lastEventTime', activation.lastEventTime, props.locale), + { label: 'lastRecordId', value: activation.lastRecordId }, + ...(activation.terminalRecordId + ? [{ label: 'terminalRecordId', value: activation.terminalRecordId }] + : []), + ...graphRunMetadata(activation.run), + ], + }))} + omitted={inspection.omitted.activations} + omittedItems={props.copy.omittedItems} + /> + ({ + key: claim.claimId, + text: `${humanizeGraphValue(claim.admissionState)} · ${claim.claimId}`, + metadata: [ + { label: 'intentId', value: claim.intentId }, + { label: 'childSessionId', value: claim.childSessionId }, + graphTimeMetadata('claimedAt', claim.claimedAt, props.locale), + ...graphRunMetadata(claim.run), + ], + }))} + omitted={inspection.omitted.claims} + omittedItems={props.copy.omittedItems} + /> + ({ + key: record.recordId, + text: `${record.facets.map(humanizeGraphValue).join(', ') || humanizeGraphValue('runtime_activity')} · ${record.recordId}`, + metadata: [ + { label: 'activationId', value: record.activationId }, + graphTimeMetadata('eventTime', record.eventTime, props.locale), + { + label: 'signals', + value: + record.signals.map(graphSignalLabel).join(', ') || humanizeGraphValue('none'), + }, + ...graphRunMetadata(record.run), + ], + }))} + omitted={inspection.omitted.records} + omittedItems={props.copy.omittedItems} + /> + + ) : null} + +
    + ); +} + +function AgentGraphDetailCollection(props: { + label: string; + items: readonly { + key: string; + text: string; + metadata?: readonly { label: string; value: ReactNode }[]; + }[]; + omitted: number; + omittedItems(count: number): string; +}) { + if (props.items.length === 0 && props.omitted === 0) return null; + return ( +
    + {props.label} +
      + {props.items.map((item) => ( +
    • + {item.text} + {item.metadata ? ( +
      + {item.metadata.map((entry) => ( +
      +
      {entry.label}
      +
      {entry.value}
      +
      + ))} +
      + ) : null} +
    • + ))} + {props.omitted > 0 ?
    • {props.omittedItems(props.omitted)}
    • : null} +
    +
    + ); +} + +function humanizeGraphValue(value: string): string { + return value.replaceAll('_', ' '); +} + +function graphRunMetadata( + run: AgentGraphClientRunRef, +): readonly { label: string; value: string }[] { + return [ + { label: 'run.sessionId', value: run.sessionId }, + { label: 'run.agentRunId', value: run.agentRunId }, + ...(run.turnId ? [{ label: 'run.turnId', value: run.turnId }] : []), + ]; +} + +function graphTimeMetadata( + label: string, + timestamp: number, + locale: UiLocale, +): { label: string; value: ReactNode } { + const date = new Date(timestamp); + return { + label, + value: ( + + ), + }; +} + +function graphSignalLabel( + signal: AgentGraphOperatorInspection['recentRecords'][number]['signals'][number], +): string { + return signal.kind === 'attention' + ? `${signal.kind}: ${humanizeGraphValue(signal.reason)}` + : `${signal.kind}: ${humanizeGraphValue(signal.status)}`; +} + +function agentName(snapshot: AgentGraphClientSnapshot, operatorId: string): string { + return ( + snapshot.operators.find((operator) => operator.operatorId === operatorId)?.agentId ?? operatorId + ); +} + function sameEpochPage( cached: AgentGraphEpochDirectory, currentPage: AgentGraphEpochDirectory, @@ -539,30 +1027,54 @@ function sameEpochPage( }); } -function firstWait(operator: AgentGraphClientOperator) { - return operator.readiness.find((readiness) => readiness.status === 'waiting')?.waitingFor[0]; -} - function waitReasonEn(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}`; - } - if (wait.kind === 'activation_missing') { - return `Waiting for ${wait.operatorId} activation`; - } - return `Waiting for ${wait.operatorId} to settle`; + const waits = operator.readiness + .filter((readiness) => readiness.status === 'waiting') + .flatMap((readiness) => readiness.waitingFor); + const wait = waits[0]; + const reason = wait + ? wait.kind === 'input_route' + ? `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}` + : wait.kind === 'activation_missing' + ? `Waiting for ${wait.operatorId} activation` + : `Waiting for ${wait.operatorId} to settle` + : undefined; + const moreWaits = Math.max(0, waits.length - 1); + const parts = [ + reason, + ...(moreWaits > 0 ? [`${moreWaits} more wait${moreWaits === 1 ? '' : 's'}`] : []), + ...(operator.omitted.readinessWaits > 0 + ? [`${operator.omitted.readinessWaits} wait${operator.omitted.readinessWaits === 1 ? '' : 's'} omitted`] + : []), + ...(operator.omitted.readiness > 0 + ? [`${operator.omitted.readiness} readiness check${operator.omitted.readiness === 1 ? '' : 's'} omitted`] + : []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : undefined; } function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `等待 ${wait.upstreamOperatorIds.join('、')} 的输入`; - } - if (wait.kind === 'activation_missing') { - return `等待 ${wait.operatorId} activation`; - } - return `等待 ${wait.operatorId} 结束`; + const waits = operator.readiness + .filter((readiness) => readiness.status === 'waiting') + .flatMap((readiness) => readiness.waitingFor); + const wait = waits[0]; + const reason = wait + ? wait.kind === 'input_route' + ? `等待 ${wait.upstreamOperatorIds.join('、')} 的输入` + : wait.kind === 'activation_missing' + ? `等待 ${wait.operatorId} activation` + : `等待 ${wait.operatorId} 结束` + : undefined; + const moreWaits = Math.max(0, waits.length - 1); + const parts = [ + reason, + ...(moreWaits > 0 ? [`另有 ${moreWaits} 项等待条件`] : []), + ...(operator.omitted.readinessWaits > 0 + ? [`${operator.omitted.readinessWaits} 项等待条件已省略`] + : []), + ...(operator.omitted.readiness > 0 + ? [`${operator.omitted.readiness} 项 readiness 检查已省略`] + : []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' · ') : undefined; } diff --git a/apps/desktop/src/renderer/agent-graph-topology.tsx b/apps/desktop/src/renderer/agent-graph-topology.tsx new file mode 100644 index 0000000000..2ebd8f1d6e --- /dev/null +++ b/apps/desktop/src/renderer/agent-graph-topology.tsx @@ -0,0 +1,333 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useId, useLayoutEffect, useMemo, useRef } from 'react'; +import type { + AgentGraphClientEdge, + AgentGraphClientOperator, + AgentGraphClientScheduledWork, + AgentGraphClientSnapshot, +} from '@maka/runtime/stream-graph-read-model'; +import { StatusDot } from '@astryxdesign/core/StatusDot'; +import { dotForStatus, type StatusSemantic } from '@maka/ui'; + +const NODE_WIDTH = 208; +const NODE_HEIGHT = 104; +const COLUMN_GAP = 72; +const ROW_GAP = 24; +const CANVAS_PADDING = 20; + +function compareIdentity(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +export interface AgentGraphNodePosition { + readonly operatorId: string; + readonly x: number; + readonly y: number; +} + +export interface AgentGraphLayout { + readonly width: number; + readonly height: number; + readonly nodes: readonly AgentGraphNodePosition[]; +} + +export function revealAgentGraphNode( + viewport: Pick, + position: AgentGraphNodePosition, +): void { + if (position.x < viewport.scrollLeft) viewport.scrollLeft = position.x; + if (position.x + NODE_WIDTH > viewport.scrollLeft + viewport.clientWidth) { + viewport.scrollLeft = position.x + NODE_WIDTH - viewport.clientWidth; + } + if (position.y < viewport.scrollTop) viewport.scrollTop = position.y; + if (position.y + NODE_HEIGHT > viewport.scrollTop + viewport.clientHeight) { + viewport.scrollTop = position.y + NODE_HEIGHT - viewport.clientHeight; + } +} + +export function firstScheduledWorkPreview( + operator: AgentGraphClientOperator, + work: readonly AgentGraphClientScheduledWork[], +): string | undefined { + return work + .filter((candidate) => operator.scheduledWorkIds.includes(candidate.workId)) + .sort( + (left, right) => + Number(right.status === 'requested') - Number(left.status === 'requested') || + right.revision - left.revision || + right.committedAt - left.committedAt || + compareIdentity(right.workId, left.workId), + )[0]?.instructionPreview; +} + +export function scheduledWorkPresentation( + operator: AgentGraphClientOperator, + work: readonly AgentGraphClientScheduledWork[], +): { preview: string | undefined; omitted: number } { + const visibleWorkIds = new Set(work.map((entry) => entry.workId)); + const omittedVisibleReferences = operator.scheduledWorkIds.filter( + (workId) => !visibleWorkIds.has(workId), + ).length; + return { + preview: firstScheduledWorkPreview(operator, work), + omitted: operator.omitted.scheduledWorkIds + omittedVisibleReferences, + }; +} + +const OPERATOR_STATUS_SEMANTICS = { + not_started: 'neutral', + waiting: 'neutral', + runnable: 'active', + running: 'active', + blocked: 'attention', + completed: 'success', + failed: 'error', + aborted: 'error', + cancelled: 'neutral', +} satisfies Record; + +export function agentGraphStatusSemantic( + status: AgentGraphClientOperator['status'], +): StatusSemantic { + return OPERATOR_STATUS_SEMANTICS[status]; +} + +export function AgentGraphStatusDot(props: { + status: AgentGraphClientOperator['status']; + label: string; +}) { + return ( +