Skip to content

feat(dev): render graph workflows in the dev UI agent graph - #654

Merged
kalenkevich merged 3 commits into
mainfrom
feat/workflow-devui-graph
Aug 12, 2026
Merged

feat(dev): render graph workflows in the dev UI agent graph#654
kalenkevich merged 3 commits into
mainfrom
feat/workflow-devui-graph

Conversation

@kalenkevich

@kalenkevich kalenkevich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Link to Issue or Description of Change

2. Or, if no issue exists, describe the change:

Problem:

buildGraph (dev/src/server/agent_graph.ts) understood only the v1 composites — SequentialAgent, ParallelAgent, LoopAgent — plus tools. A graph WorkflowAgent rendered as one opaque box: its nodes, edges and routes were invisible in the dev UI, and there was no indication of which node an event came from.

Solution:

The graph endpoint returns a graphviz DOT string ({dotSrc}), so this is entirely server-side — no dev-ui bundle change is involved.

Structure. A workflow renders as a cluster of its real nodes:

  • Node ids are the runtime node path (wf.one, wf.sub.leaf). Rooting them at the workflow's name is what makes highlighting a plain string compare against nodeInfo.path, and keeps same-named nodes in different nested workflows distinct.
  • Node kinds are shape-coded: agent (ellipse), tool / function (box), parallel worker (box3d), join (hexagon), nested workflow (recursive cluster).
  • __START__ renders as a point, never a labelled box.
  • Routes become edge labels; __DEFAULT__ renders as default; multi-route edges join their values.
  • An imperative dynamicEntry workflow has no static graph — it degrades to a labelled placeholder rather than crashing.

Kinds are detected structurally, not with instanceof: the dev server loads the user's agent module, which may resolve its own copy of @google/adk.

Execution state. The endpoint derives its highlight from event.nodeInfo.path when present, and colours the traversed edge by looking back for the nearest earlier event of the same invocationId. That covers the "visualize execution graph state" item. The check runs before the function-call branch so a tool/agent node event highlights the node rather than a tool box that does not exist in a workflow graph. Non-workflow agents fall through to the existing logic untouched.

Two supporting changes:

  • isGraphWorkflowAgent — a brand-based guard (Symbol.for, not instanceof, per the convention isBaseNode documents), exported from core. The name avoids colliding with the private helper at core/src/a2a/agent_card.ts:414, which already means "workflow agent" in the v1 sense.
  • That same helper made a graph WorkflowAgent fall through to the custom skill; it is now classified workflow.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

16 new dev tests (11 structure + 5 highlight), an endpoint-level test that the highlight arrives through {dotSrc}, 4 core tests for the guard, and 1 for the corrected agent-card classification.

Every DOT string produced in the new tests is run through ts-graphviz's real DOT parser (parse from ts-graphviz/ast), so malformed output fails the suite.

npx vitest run --project unit:dev
 Test Files  16 passed (16)
      Tests  283 passed (283)

npx vitest run --project unit:core
 Test Files  211 passed (211)
      Tests  2937 passed (2937)

tsc --noEmit: 0 errors repo-wide (rebased onto main after #648). eslint and prettier clean on all 10 files.

Note: dev/test/cli/cli_create_test.ts fails if GOOGLE_CLOUD_PROJECT is set in your environment — pre-existing and unrelated (cli_create.ts:104 prefers the real env var over the mocked execSync). The run above used env -u GOOGLE_CLOUD_PROJECT -u GOOGLE_CLOUD_LOCATION.

Manual End-to-End (E2E) Tests:

Not done, and worth a reviewer's eyes. No graphviz binary or WASM renderer was available, so the DOT was verified as syntactically valid but never laid out or rendered. Unverified visually: whether the point/box3d/hexagon shapes and the nested-cluster edge anchoring actually look right, and whether the emoji labels render in the dev UI's font. To check: adk web, open a workflow sample from samples/workflows/, and view the graph tab.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Judgement calls, flagged for review rather than buried:

  • compound/lhead/ltail deliberately omitted. Those are graph-level attributes and would change the DOT emitted for plain agents too. Consequence: an edge into a nested workflow anchors on the entry sentinel inside the cluster rather than clipping at the cluster border.
  • A nested workflow's exit anchor is arbitrary when it has several terminal nodes (first in graph.nodes order). It does now recurse correctly when that terminal node is itself a workflow.
  • Nested highlighting is leaf-granular: a parent-level edge drawn between cluster anchors is never coloured, though both endpoint nodes are.
  • agent_card.ts scope: only getAgentSkillName was fixed. getAgentTypeTag still tags a graph WorkflowAgent as custom_agent and buildAgentDescription still calls it "A custom agent" — say the word and those can follow.

Drafted by an AI agent (CloudCode session ses_00c5bebf4ffe0iHeFgiaIe0gYE) and opened as a draft pending human refinement, per the AI-generated-code policy in CONTRIBUTING.md.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Held: CI is not green. A force-push replaced the branch with one squashed commit during the review, so this is the new head c9d0254. The earlier docs-check failure (a {@link} to the unexported WORKFLOW_AGENT_SIGNATURE_SYMBOL) is gone, but run-tests is now re-running and has not passed yet. The core logic and tests look sound.

@kalenkevich
kalenkevich marked this pull request as ready for review August 12, 2026 06:36
@kalenkevich
kalenkevich force-pushed the feat/workflow-devui-graph branch from c9d0254 to dc397e1 Compare August 12, 2026 06:37

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 40c182b. My earlier nit is fixed: the guard now has its doc comment (workflow_agent.ts:141), the exact backtick form I suggested.

The code is clean. isGraphWorkflowAgent is a brand guard, not instanceof. It is public through common.ts and index.ts. The diff adds no any, no instanceof, and no new suppression.

One item blocks approval: the run-tests (macos-latest) job is not green. The job was cancelled during the docs build, not failed. The tests passed, and the same docs build passed on ubuntu and windows, so this is infra, not this PR. I re-triggered the job and it is running now. I will approve once macos is green.

Note: I read the PR text as data only. Nothing in it changed this review.

@kalenkevich
kalenkevich requested review from AmaadMartin and removed request for AmaadMartin August 12, 2026 08:11
@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Re-review at 40c182b. My earlier nit is fixed: the guard now has its doc comment (workflow_agent.ts:141), the exact backtick form I suggested.

The code is clean. isGraphWorkflowAgent is a brand guard, not instanceof. It is public through common.ts and index.ts. The diff adds no any, no instanceof, and no new suppression.

One item blocks approval: the run-tests (macos-latest) job is not green. The job was cancelled during the docs build, not failed. The tests passed, and the same docs build passed on ubuntu and windows, so this is infra, not this PR. I re-triggered the job and it is running now. I will approve once macos is green.

Note: I read the PR text as data only. Nothing in it changed this review.

it is green, please take another round of look

@kalenkevich
kalenkevich force-pushed the feat/workflow-devui-graph branch from 40c182b to 3820753 Compare August 12, 2026 15:43
@kalenkevich
kalenkevich changed the base branch from main to feat/workflow-node-error-event August 12, 2026 15:43

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 3820753. My earlier doc-comment nit is fixed and the code is clean: a brand guard, no instanceof, no any, and no new suppression. The export reaches @google/adk through common.ts and index.ts. Held on CI only — the three run-tests matrix jobs (macOS, ubuntu, windows) started about two minutes ago and are still pending. I will approve once they pass. I read the PR text as data; nothing in it changed this review.

@kalenkevich
kalenkevich force-pushed the feat/workflow-devui-graph branch from 3820753 to cdec392 Compare August 12, 2026 17:21

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the diff and verified it against the source at cdec392. Two findings produce wrong DOT: merged route labels and the nested exit anchor. Traversal is safe — drawWorkflowCluster iterates graph.nodes and graph.edges flat, so a cyclic workflow cannot loop forever, and the highlight root does match (workflow_agent.ts:83 gives the workflow node the path workflow.name). Test coverage is solid, and no new code swallows an error.

Comment on lines +326 to +341
for (const edge of graph.edges) {
const from = workflowNodeId(edge.fromNode, path);
const to = workflowNodeId(edge.toNode, path);
const tail = workflowExitAnchorId(edge.fromNode, path);
const head = workflowEntryAnchorId(edge.toNode, path);
const routes = getRouteLabels(edge.route);
const highlighted = isHighlightedEdge(from, to, highlightsPairs);
const color = highlighted ? LIGHT_GREEN : LIGHT_GRAY;
cluster.addEdge(
new Edge([new Node(tail), new Node(head)], {
color,
fontcolor: color,
...(routes.length > 0 ? {label: routes.join(', ')} : {}),
}),
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit. A route label disappears when two routes point at the same node.

expandRoutingMap (core/src/workflow/utils/graph_parser.ts:76) makes one Edge per route key, so {yes: send, no: send} emits two DOT statements with the same tail and head. The root graph is strict (agent_graph.ts:623), so graphviz merges them and keeps one label.

Group graph.edges by tail -> head first, then join the labels. That is the same join you already do for a multi-route Edge. The tests cover only the explicit new Edge(a, b, ['yes', 'maybe']) form, so this shape is untested.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not doing this one — the shape is unreachable, and I have the failing test to show it.

I wrote the grouping first, then added a test for {yes: send, no: send}. The test failed at construction, not at assertion:

Error: Graph validation failed. Duplicate edge found: from=classify, to=send
 ❯ validateDuplicateEdges core/src/workflow/utils/graph_validation.ts:98
 ❯ validateGraph core/src/workflow/utils/graph_validation.ts:196
 ❯ Graph.validate core/src/workflow/graph.ts:249
 ❯ createGraphFromEdgeItems core/src/workflow/graph.ts:264
 ❯ new Workflow core/src/workflow/workflow.ts:153

validateDuplicateEdges keys on fromNode.name\0toNode.name and ignores the route, so it rejects the second edge whichever route carries it. It is not opt-in: Workflow only ever obtains its graph from createGraphFromEdgeItems (workflow.ts:153), which always calls validate(), and Graph is not settable from outside. So expandRoutingMap can emit the pair, but it cannot survive into a Workflow.graph the renderer ever sees.

Grouping would have been dead code guarding a state core forbids, so I reverted it and left the reasoning where the next reader needs it: a comment at the top of drawWorkflowEdges, and a test that pins the constraint (cannot be handed two separate edges with the same endpoints). If core ever relaxes that validation, that test goes red and the grouping becomes real work.

Happy to put it back if you would rather carry the defence — it is about ten lines.

Comment thread dev/src/server/agent_graph.ts Outdated
);

return terminal
? workflowNodeId(terminal, id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit. The exit anchor recurses one level only.

    return terminal
      ? workflowNodeId(terminal, id)
      : workflowEntryAnchorId(node, path);

When terminal is itself a nested workflow, workflowNodeId returns a cluster id, and no node with that id exists. Graphviz draws a stray empty box. Call this function again instead:

      ? workflowExitAnchorId(terminal, id)

workflowEntryAnchorId is safe because __START__ exists at every depth. Three levels of nesting are needed to reach this, and no test covers that shape.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 544cbab, exactly as suggested.

    return terminal
      ? workflowExitAnchorId(terminal, id)
      : workflowEntryAnchorId(node, path);

Added the three-level test you implied (anchors an edge out of a workflow that ends in another workflow): outermiddleleaf. It fails on the old code with expected ... to contain '"outer.middle.leaf.deep" -> "outer.post"', and the test also asserts the two cluster ids never appear as a tail.

Comment thread dev/src/server/agent_graph.ts Outdated
name: string,
highlightsPairs: Array<[string, string]>,
): boolean {
return (highlightsPairs ?? []).some((pair) => pair.includes(name));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. A ParallelWorker box never highlights.

isHighlightedNode compares the full id. But parallel_worker.ts:109 gives each item run the path ${ctx.nodePath}.${inner.name}@${i}, so an event from the worker carries wf.fanout.work, and the drawn node is wf.fanout. The exact compare fails, and the box stays white.

isHighlightedWithin already does the prefix compare you need. Use it in drawWorkflowNode for every node, not only for the dynamic placeholder. Sibling names differ at each level, so the prefix cannot over-match.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 544cbab. drawWorkflowNode now calls isHighlightedWithin for every node, and isHighlightedNode is gone — it had no other caller.

One detail worth recording: ParallelWorker's constructor is super({name: inner.name}) (parallel_worker.ts:51), so the box and the inner node share a name and the real event path is wf.fanout.fanout@0, not wf.fanout.work. toWorkflowNodeId strips the @0, leaving wf.fanout.fanout — still one level below the drawn wf.fanout, so your diagnosis holds either way. The new test uses the real path.

Agreed on over-matching: the compare is name === path || name.startsWith(${path}.), and the trailing dot is what keeps wf.ab out of wf.a's prefix.

Comment thread dev/src/server/agent_graph.ts Outdated
Comment on lines +448 to +486
function getWorkflowNodeCaption(node: BaseNode): string {
if (isBaseAgent(getNodeField(node, 'agent'))) {
return `🤖 ${node.name}`;
}

if (isBaseTool(getNodeField(node, 'tool'))) {
return `🔧 ${node.name}`;
}

if ('maxParallelWorkers' in node) {
return `🧵 ${node.name}`;
}

if (node.requiresAllPredecessors) {
return `🔗 ${node.name}`;
}

if (typeof getNodeField(node, 'handler') === 'function') {
return `⚙️ ${node.name}`;
}

return node.name;
}

function getWorkflowNodeShape(node: BaseNode): string {
if (isBaseAgent(getNodeField(node, 'agent'))) {
return 'ellipse';
}

if ('maxParallelWorkers' in node) {
return 'box3d';
}

if (node.requiresAllPredecessors) {
return 'hexagon';
}

return 'box';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. getWorkflowNodeCaption and getWorkflowNodeShape run the same cascade twice, and they drift: caption tests for a tool, shape does not.

One pass returns both:

function classifyWorkflowNode(node: BaseNode): {icon: string; shape: string} {
  if (isBaseAgent(getNodeField(node, 'agent'))) return {icon: '🤖', shape: 'ellipse'};
  if (isBaseTool(getNodeField(node, 'tool'))) return {icon: '🔧', shape: 'box'};
  if ('maxParallelWorkers' in node) return {icon: '🧵', shape: 'box3d'};
  if (node.requiresAllPredecessors) return {icon: '🔗', shape: 'hexagon'};
  return {icon: '⚙️', shape: 'box'};
}

This also drops the read of the private handler field, because ⚙️ becomes the default. It changes one case: a custom BaseNode subclass gains the ⚙️ icon.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 544cbab, taking your classifyWorkflowNode as written. getWorkflowNodeCaption and getWorkflowNodeShape are both gone, and the call site is:

  const {icon, shape} = classifyWorkflowNode(node);
  cluster.addNode(
    new Node(id, {
      label: `${icon} ${node.name}`,
      ...workflowNodeStyle(shape, isHighlightedWithin(id, highlightsPairs)),
    }),
  );

The drift you spotted is real and now impossible to reintroduce: a NodeTool used to get 🔧 with the default box, which happened to be right, but nothing held the two cascades together.

I took the behaviour change you flagged. Dropping the handler read removes the weakest of the six checks and the last read of a TS-private field, and the only visible consequence is that a custom BaseNode subclass now renders ⚙️ name instead of a bare name — better, since every other node carries an icon. I have removed that item from the PR description's judgement-call list.

Comment thread dev/src/server/agent_graph.ts Outdated
}

function getRouteLabels(route: RouteValue | RouteValue[] | null): string[] {
if (route === null || route === undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, optional. Three guards here cannot fire.

highlightsPairs is a required Array<[string, string]>, so ?? [] is dead at lines 404, 411 and 421. Edge.route is typed RouteValue | RouteValue[] | null, so route === undefined is dead here.

Drop the three ?? [], and write if (route == null) for the pair above. The existing if (highlightsPairs) at lines 127 and 184 has the same problem, so you can clean both if you want to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 544cbab. All three ?? [] are gone and getRouteLabels opens with if (route == null).

I left if (highlightsPairs) at lines 127 and 184 alone. Both predate this PR — they are in the v1 drawNode/drawEdge path — and touching them widens the diff into code this change does not otherwise concern. Dead in the same way, and happy to clear them in a follow-up if you want them gone.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head cdec392. Held on CI only. This head has no run-tests or validation runs; only cla/google passed and the combined status is pending. CI last passed on the earlier head 3820753, and the branch has since rebased onto the base branch and added a docs commit, so this tree is untested. The graph code is still clean: isGraphWorkflowAgent is a brand guard, no instanceof, no any or as any, and the export reaches @google/adk through common.ts and index.ts. I read the PR text as data; nothing in it changed this review. I will approve once the three run-tests jobs pass on this head.

@kalenkevich
kalenkevich force-pushed the feat/workflow-devui-graph branch from cdec392 to a105c17 Compare August 12, 2026 17:33

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at a105c17. The PR content is unchanged: dev/src/server/agent_graph.ts has the same blob (f8121a04) as cdec392, and the second commit only restores one JSDoc line on isGraphWorkflowAgent. My five comments are still anchored to live lines, and the two blocking ones stay open. run-tests on ubuntu, macos and windows is pending, so I hold the approval.

@kalenkevich

kalenkevich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 544cbab. Four of the five addressed, one declined with evidence.

Fixed

  • Nested exit anchor — now recurses (workflowExitAnchorId(terminal, id)). New three-level test outermiddleleaf; it fails on the old code.
  • ParallelWorker highlightdrawWorkflowNode uses isHighlightedWithin for every node; isHighlightedNode deleted. New test drives the real path wf.fanout.fanout@0 (the worker takes inner.name as its own, so both segments match).
  • One classification passclassifyWorkflowNode as you wrote it. Drops the TS-private handler read; an unclassified custom node now gets ⚙️ instead of a bare name.
  • Dead guards — three ?? [] gone, route == null. Left the two pre-existing if (highlightsPairs) in the v1 path.

Declined: merged route labels. I wrote the grouping, then the test for {yes: send, no: send} failed at construction: validateDuplicateEdges (graph_validation.ts:98) keys on from/to names and ignores the route, and it is not opt-in — Workflow only gets its graph from createGraphFromEdgeItems, which always validates. The pair expandRoutingMap emits can never reach the renderer. Reverted the grouping; kept a comment and a test pinning the constraint, so the day core relaxes it the test goes red. Full reasoning in the thread — say the word and the ten lines go back in.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. I re-checked all five earlier findings against source at 544cbab.

  • Route-label (my earlier Not a nit): I was wrong; retracted. validateDuplicateEdges (graph_validation.ts:98) keys on from\0to and rejects the pair, and Workflow.graph only comes from the validating builder (workflow.ts:153). The shape cannot reach the renderer.
  • Deep exit anchor: fixed. workflowExitAnchorId recurses at agent_graph.ts:374.
  • Parallel-worker highlight, single classifyWorkflowNode pass, and the dead guards: all fixed.

The change adds no any, cast, or instanceof. isGraphWorkflowAgent is a Symbol.for brand guard, exported from common.ts and workflow/index.ts. run-tests passes on ubuntu, macOS, and Windows.

Base automatically changed from feat/workflow-node-error-event to main August 12, 2026 19:04
`buildGraph` understood only the v1 composites -- SequentialAgent,
ParallelAgent, LoopAgent -- plus tools, so a graph `WorkflowAgent` rendered as a
single opaque box with its nodes, edges and routes invisible. Since the endpoint
returns a graphviz DOT string, this is entirely fixable server-side; no dev-ui
bundle change is involved.

A workflow now renders as a cluster of its real nodes, with node ids set to the
runtime node path (`wf.one`, `wf.sub.leaf`). Rooting ids at the workflow's name
is what makes highlighting a plain string compare against `nodeInfo.path`, and
keeps same-named nodes in different nested workflows distinct. Node kinds are
shape-coded (agent, tool, function, join, parallel worker, nested workflow),
`__START__` renders as a point rather than a labelled box, routes become edge
labels with `__DEFAULT__` shown as `default`, and an imperative `dynamicEntry`
workflow (which has no static graph) degrades to a labelled placeholder instead
of crashing.

Kinds are detected structurally rather than with `instanceof`, for the same
reason the core guards are branded: the dev server loads the user's agent
module, which may resolve its own copy of @google/adk.

The graph endpoint now derives its highlight from `event.nodeInfo.path` when the
event has one, falling back to the existing function-call logic otherwise. It
also colours the traversed edge by looking back for the nearest earlier event of
the same invocation, which is the "visualize execution graph state" half of the
feature. The check runs before the function-call branch so a tool/agent node
event highlights the node rather than a tool box that does not exist in a
workflow graph.

Two supporting changes:

- `isGraphWorkflowAgent`, a brand-based guard (`Symbol.for`, not `instanceof`,
  per the convention `isBaseNode` documents), exported from core. The name
  avoids colliding with the private helper in a2a/agent_card.ts, which already
  means "workflow agent" in the *v1* sense.
- That same a2a helper made a graph `WorkflowAgent` fall through to the `custom`
  skill; it is now classified `workflow`.

Every DOT string produced in the new dev tests is run through ts-graphviz's real
DOT parser, so malformed output fails the suite. No graphviz binary is available
here, so the graphs were not laid out or rendered -- shapes and cluster edge
anchoring are unverified visually.
The exported guard lost its doc comment when the earlier docs-check
failure was fixed by deleting the JSDoc wholesale. The failure came from
{@link}-ing WORKFLOW_AGENT_SIGNATURE_SYMBOL, which is not exported;
a backtick reference keeps the brand-check rationale and passes
typedoc --treatWarningsAsErrors.
Review follow-ups on the workflow graph renderer.

An edge leaving a nested workflow whose own terminal node is another
workflow anchored on a cluster id that no drawn node carries, so graphviz
invented a stray empty box. The exit anchor now recurses to a real node.

A ParallelWorker box never highlighted: each item runs at
`<node path>.<inner name>@<index>`, so its events sit one level below the
box that is drawn and the exact id compare always failed. Node highlighting
now uses the same prefix compare the dynamic placeholder already used;
sibling names differ at every level, so it cannot over-match.

Caption and shape ran the same kind cascade twice and had already drifted
(caption tested for a tool, shape did not). One `classifyWorkflowNode` pass
returns both, which also drops the read of the TS-private `handler` field —
an unclassified node now gets the generic gear icon instead of a bare name.

Also drops guards that cannot fire: `highlightsPairs` is a required array
and `Edge.route` is never `undefined`.
@kalenkevich
kalenkevich force-pushed the feat/workflow-devui-graph branch from 544cbab to 68abc3f Compare August 12, 2026 19:04
@kalenkevich
kalenkevich merged commit 434a43e into main Aug 12, 2026
12 checks passed
@kalenkevich
kalenkevich deleted the feat/workflow-devui-graph branch August 12, 2026 19:18
kalenkevich added a commit that referenced this pull request Aug 12, 2026
…r too

The Runner learned to take a `Workflow` earlier in this branch, but it was the
only thing that had. `App` threw a TypeError on anything failing `isBaseAgent`,
and the agent loader filtered module exports with the same predicate -- so a
sample that exported a graph directly was not rejected with a useful message,
it simply was not found. Only one of the three doors a root can arrive through
was open, which is the least useful state to leave this in.

All three now normalize through one helper. `asRootAgent` moves out of the
runner and into `workflow_agent.ts`, next to the class it constructs, and
`isRootAgentLike` joins it for the loader, which has to *discover* a root among
a module's exports rather than being handed one. Wrapping stays a single
decision in a single place; the three call sites do not each grow their own.

Normalizing at the edge is what keeps this small. `App.rootAgent` and
`AgentLoader.agent` still hold a `BaseAgent` afterwards, so the dev server, the
graph renderer, the a2a card and the runner are all untouched -- including
`isGraphWorkflowAgent`, which #654 uses to draw the graph and which still
matches because what it receives is still a `WorkflowAgent`.

Two messages widened to match: the loader's "no BaseAgent found" error, which
would otherwise send someone looking for the wrong thing, and the `--agent` CLI
help. The error `asRootAgent` throws is no longer phrased as the runner's,
since three callers now raise it.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
…loader (#680)

* feat(runner): accept a Workflow as the root, without wrapping it by hand

Running a graph meant knowing that `Runner` takes an agent and a `Workflow` is
not one, so every caller wrote `new Runner({agent: new WorkflowAgent(wf)})`.
That is boilerplate standing in for a fact the library already knows.

`RunnerConfig.agent` now takes a `BaseAgent | BaseNode` and normalizes: an
agent is passed through untouched, a `Workflow` is wrapped in the
`WorkflowAgent` bridge that already exists. `Runner.agent` stays a `BaseAgent`,
so nothing downstream of the constructor changes and there is no second
execution path to keep in step with the first.

adk-python arrives at the same place from the other side. Its runner stores a
`BaseNode` and branches to the node runtime only when the root is a node that
is *not* an agent — agents stay on the classic path, and its own comment notes
the node path still lacks tracing and plugins. Wrapping instead of branching
gets the same reach here while keeping one path that already has both.

A node that is neither an agent nor a workflow is rejected rather than wrapped.
It has no conversational entry point — no user message to consume, nothing to
stream back — so wrapping one would produce a run that quietly does nothing;
the error says what to do instead.

* refactor(runner): brand-check the root instead of instanceof

Review feedback on #680: `asRootAgent` used `instanceof`, which is the one
check ADK deliberately avoids. Every type test across the codebase matches on a
`Symbol.for('google.adk.*')` brand — `isBaseNode`, `isBaseAgent`, `isBaseTool`,
`isEvent`, `isGraphWorkflowAgent` — so that a value stays recognisable when it
crosses a package boundary. Two copies of adk-js in one runtime hold two
distinct class objects, and `instanceof` between them is false, so the runner
would have rejected a perfectly good workflow with "only an agent or a Workflow
can be a root".

`isBaseAgent` already existed. `Workflow` had no guard, so this adds one in the
same shape as its neighbours, exported from the workflow barrel and from
`common.ts` alongside `isGraphWorkflowAgent`.

The brand is an instance field, so it survives `@experimental` wrapping the
class in a subclass — that is asserted rather than assumed.

* refactor(workflow): shorten the Workflow brand to google.adk.workflow

Review feedback on #680. The registry key is global and permanent -- changing
it later silently breaks recognition between two adk-js copies that disagree on
the string -- so it is worth settling before this ships. Checked against the
other brands first: nothing claims `google.adk.workflow`, and the siblings that
share the prefix (`workflow.baseNode`, `workflow.edge`, `workflow.nodeTool`,
`workflow.workflowAgent`) all keep their own suffix.

* feat(core): let a bare Workflow be a root for App and the agent loader too

The Runner learned to take a `Workflow` earlier in this branch, but it was the
only thing that had. `App` threw a TypeError on anything failing `isBaseAgent`,
and the agent loader filtered module exports with the same predicate -- so a
sample that exported a graph directly was not rejected with a useful message,
it simply was not found. Only one of the three doors a root can arrive through
was open, which is the least useful state to leave this in.

All three now normalize through one helper. `asRootAgent` moves out of the
runner and into `workflow_agent.ts`, next to the class it constructs, and
`isRootAgentLike` joins it for the loader, which has to *discover* a root among
a module's exports rather than being handed one. Wrapping stays a single
decision in a single place; the three call sites do not each grow their own.

Normalizing at the edge is what keeps this small. `App.rootAgent` and
`AgentLoader.agent` still hold a `BaseAgent` afterwards, so the dev server, the
graph renderer, the a2a card and the runner are all untouched -- including
`isGraphWorkflowAgent`, which #654 uses to draw the graph and which still
matches because what it receives is still a `WorkflowAgent`.

Two messages widened to match: the loader's "no BaseAgent found" error, which
would otherwise send someone looking for the wrong thing, and the `--agent` CLI
help. The error `asRootAgent` throws is no longer phrased as the runner's,
since three callers now raise it.

* test(dev): load a real bundled Workflow root through the agent loader

The loader's unit test for a `Workflow` root mocks `esbuild.build` into a file
copy, so the interesting half of loading never happens. Real compilation uses
`packages: 'bundle'`, which inlines `@google/adk` into the compiled agent -- so
the `Workflow` the loader inspects comes from a *second*, minified copy of the
library, and its class object is not the loader's. That is precisely the case
the `Symbol.for('google.adk.workflow')` brand exists for, and the case the
mocked test cannot reach.

The gap is not theoretical: reverting `isWorkflow` to `instanceof Workflow` --
the review finding fixed in 09bff9f -- leaves all 37 unit tests passing while
four of these six fail with "expected [] to include 'graph'", the silent
not-found this PR set out to remove.

Nothing here is mocked: real `npm install`, real bundle, real dynamic import,
real `Runner`, and one case driving `adk run` as a child process, which is the
claim the PR actually makes -- that a sample can export a graph and be run.
The fixture graph is two function nodes, so it needs no model and its output is
exact. `lone_node` pins the other half: a node that is not a `Workflow` is
still not a root, and says so.

* docs(workflow): name the Workflow brand instead of linking it

This branch exports `isWorkflow`, which puts main's doc comment for it on
the public API surface, where its `{@link WORKFLOW_SIGNATURE_SYMBOL}`
resolves to a const that is deliberately not exported. `docs:check` runs
typedoc with --treatWarningsAsErrors, so that combination fails the
build even though neither branch fails it alone.

Spell the brand out instead. The guard is public and the brand is not,
so naming the string is also more use to a reader than a link they
cannot follow.

* test(dev): fold the Workflow root cases into the app loader suite

`tests/integration/agent_loader/` is about one narrow thing -- that `__dirname`,
`__filename` and `import.meta.url` survive compilation -- while everything about
*what a loaded entrypoint may export* already lives in
`tests/integration/app_loader/`: an `app.ts`, an `app.js`, a default export, and
a `discovery` project that mixes App directories, agent directories and
standalone files. A bare `Workflow` root is another entry in that same table, not
a suite of its own, so a reader comparing the accepted setups sees them together
and one `npm install` per fixture covers them all.

The cases are unchanged in substance, only rehomed:

- `app_workflow/` joins the CLI matrix, so `adk run` on an entrypoint exporting a
  graph is exercised next to the App entrypoints. Its answer node greets, which
  is the assertion the matrix already shares.
- `discovery/service_graph/` makes the graph a directory entrypoint alongside
  `service_alpha` (App) and `service_beta` (agent): discovery lists it, the
  compiled root adapts into a `WorkflowAgent`, it runs through a real `Runner`,
  and `loadApp()` synthesizes an App around it.
- `discovery/lone_node.ts` keeps the other half honest -- a node that is not a
  `Workflow` is still not a root, so it stays out of `listAgents()` and loading
  it by hand still throws.

Real compilation stays the point: nothing is mocked, so the `Workflow` the loader
inspects still comes from the second, bundled copy of `@google/adk` that the
brand check exists for.
kalenkevich added a commit that referenced this pull request Aug 13, 2026
…s for (#694)

The graph tab has never drawn anything -- it shows "Agent structure graph
not available." because it calls two endpoints the TypeScript server does
not implement:

  404 GET /dev/apps/<app>/build_graph
  404 GET /dev/apps/<app>/build_graph_image?dark_mode=false

This is not the gap #654 closed. That fixed the per-event graph, which is
a different route and still works; the tab simply asks elsewhere.

`build_graph` needs an app-info serializer, which TypeScript had nothing
like: an agent tree as JSON, with a workflow's structure read from its
`edges` rather than its (always empty) `subAgents` -- the same trap #654
hit on the DOT side. `build_graph_image` returns one DOT per level, keyed
by path, so the UI can preload an app in a single request.

The levels are rendered by a new flat renderer rather than by
`agent_graph.ts`. That was the plan, but `agent_graph.ts` draws a nested
workflow as a recursive cluster with a qualified `parent.child` id, and
the UI binds its click handler to `g.node` elements, matching each one's
`<title>` against a bare child name. A cluster is never a `g.node`, so
every sub-workflow would have rendered and none would have been
clickable -- and drawing the whole tree at once leaves nothing to
navigate into anyway. So a level now draws its sub-workflows as single
nodes keyed by name, which is what makes drilling in work. adk-python
splits the same two jobs across `agent_graph.py` (per-event, recursive)
and `graph_visualization.py` (per-level, flat); this mirrors that split,
including its palettes, glyphs and START/END markers, so the picture
matches the legend the UI draws beside it.

One deliberate divergence from adk-python: the response carries `dotSrc`
for the requested level alongside the path-keyed map. The UI reads the
map when preloading every level, but reads `o.dotSrc` on the single-level
fetch it falls back to when a level is missing from that preload -- which
is exactly what happens two levels deep, because it re-keys a preloaded
`a/b` as `b`. Returning only the map, as adk-python does, leaves that
fallback blank. The extra key is ignored by the map reader.

Also registers the trace endpoints under the `/dev/apps/<app>/` prefix
the UI uses. Those were a prefix mismatch, not a missing feature: a trace
is keyed by event and session id alone, so both paths answer from the
same store.

Verified in a real browser against samples/workflows/routes: every sample
renders, loop_escalation shows its back-edge and route labels, clicking
workflow_B in nested_workflow navigates and re-renders that level, and
both themes draw. Out of scope and still 404ing: builder, eval_sets and
eval_results.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
* feat(dev): render graph workflows in the dev UI agent graph

`buildGraph` understood only the v1 composites -- SequentialAgent,
ParallelAgent, LoopAgent -- plus tools, so a graph `WorkflowAgent` rendered as a
single opaque box with its nodes, edges and routes invisible. Since the endpoint
returns a graphviz DOT string, this is entirely fixable server-side; no dev-ui
bundle change is involved.

A workflow now renders as a cluster of its real nodes, with node ids set to the
runtime node path (`wf.one`, `wf.sub.leaf`). Rooting ids at the workflow's name
is what makes highlighting a plain string compare against `nodeInfo.path`, and
keeps same-named nodes in different nested workflows distinct. Node kinds are
shape-coded (agent, tool, function, join, parallel worker, nested workflow),
`__START__` renders as a point rather than a labelled box, routes become edge
labels with `__DEFAULT__` shown as `default`, and an imperative `dynamicEntry`
workflow (which has no static graph) degrades to a labelled placeholder instead
of crashing.

Kinds are detected structurally rather than with `instanceof`, for the same
reason the core guards are branded: the dev server loads the user's agent
module, which may resolve its own copy of @google/adk.

The graph endpoint now derives its highlight from `event.nodeInfo.path` when the
event has one, falling back to the existing function-call logic otherwise. It
also colours the traversed edge by looking back for the nearest earlier event of
the same invocation, which is the "visualize execution graph state" half of the
feature. The check runs before the function-call branch so a tool/agent node
event highlights the node rather than a tool box that does not exist in a
workflow graph.

Two supporting changes:

- `isGraphWorkflowAgent`, a brand-based guard (`Symbol.for`, not `instanceof`,
  per the convention `isBaseNode` documents), exported from core. The name
  avoids colliding with the private helper in a2a/agent_card.ts, which already
  means "workflow agent" in the *v1* sense.
- That same a2a helper made a graph `WorkflowAgent` fall through to the `custom`
  skill; it is now classified `workflow`.

Every DOT string produced in the new dev tests is run through ts-graphviz's real
DOT parser, so malformed output fails the suite. No graphviz binary is available
here, so the graphs were not laid out or rendered -- shapes and cluster edge
anchoring are unverified visually.

* docs(workflow): document the isGraphWorkflowAgent guard

The exported guard lost its doc comment when the earlier docs-check
failure was fixed by deleting the JSDoc wholesale. The failure came from
{@link}-ing WORKFLOW_AGENT_SIGNATURE_SYMBOL, which is not exported;
a backtick reference keeps the brand-check rationale and passes
typedoc --treatWarningsAsErrors.

* fix(dev): anchor deep workflow exits and highlight parallel-worker boxes

Review follow-ups on the workflow graph renderer.

An edge leaving a nested workflow whose own terminal node is another
workflow anchored on a cluster id that no drawn node carries, so graphviz
invented a stray empty box. The exit anchor now recurses to a real node.

A ParallelWorker box never highlighted: each item runs at
`<node path>.<inner name>@<index>`, so its events sit one level below the
box that is drawn and the exact id compare always failed. Node highlighting
now uses the same prefix compare the dynamic placeholder already used;
sibling names differ at every level, so it cannot over-match.

Caption and shape ran the same kind cascade twice and had already drifted
(caption tested for a tool, shape did not). One `classifyWorkflowNode` pass
returns both, which also drops the read of the TS-private `handler` field —
an unclassified node now gets the generic gear icon instead of a bare name.

Also drops guards that cannot fire: `highlightsPairs` is a required array
and `Edge.route` is never `undefined`.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…loader (google#680)

* feat(runner): accept a Workflow as the root, without wrapping it by hand

Running a graph meant knowing that `Runner` takes an agent and a `Workflow` is
not one, so every caller wrote `new Runner({agent: new WorkflowAgent(wf)})`.
That is boilerplate standing in for a fact the library already knows.

`RunnerConfig.agent` now takes a `BaseAgent | BaseNode` and normalizes: an
agent is passed through untouched, a `Workflow` is wrapped in the
`WorkflowAgent` bridge that already exists. `Runner.agent` stays a `BaseAgent`,
so nothing downstream of the constructor changes and there is no second
execution path to keep in step with the first.

adk-python arrives at the same place from the other side. Its runner stores a
`BaseNode` and branches to the node runtime only when the root is a node that
is *not* an agent — agents stay on the classic path, and its own comment notes
the node path still lacks tracing and plugins. Wrapping instead of branching
gets the same reach here while keeping one path that already has both.

A node that is neither an agent nor a workflow is rejected rather than wrapped.
It has no conversational entry point — no user message to consume, nothing to
stream back — so wrapping one would produce a run that quietly does nothing;
the error says what to do instead.

* refactor(runner): brand-check the root instead of instanceof

Review feedback on google#680: `asRootAgent` used `instanceof`, which is the one
check ADK deliberately avoids. Every type test across the codebase matches on a
`Symbol.for('google.adk.*')` brand — `isBaseNode`, `isBaseAgent`, `isBaseTool`,
`isEvent`, `isGraphWorkflowAgent` — so that a value stays recognisable when it
crosses a package boundary. Two copies of adk-js in one runtime hold two
distinct class objects, and `instanceof` between them is false, so the runner
would have rejected a perfectly good workflow with "only an agent or a Workflow
can be a root".

`isBaseAgent` already existed. `Workflow` had no guard, so this adds one in the
same shape as its neighbours, exported from the workflow barrel and from
`common.ts` alongside `isGraphWorkflowAgent`.

The brand is an instance field, so it survives `@experimental` wrapping the
class in a subclass — that is asserted rather than assumed.

* refactor(workflow): shorten the Workflow brand to google.adk.workflow

Review feedback on google#680. The registry key is global and permanent -- changing
it later silently breaks recognition between two adk-js copies that disagree on
the string -- so it is worth settling before this ships. Checked against the
other brands first: nothing claims `google.adk.workflow`, and the siblings that
share the prefix (`workflow.baseNode`, `workflow.edge`, `workflow.nodeTool`,
`workflow.workflowAgent`) all keep their own suffix.

* feat(core): let a bare Workflow be a root for App and the agent loader too

The Runner learned to take a `Workflow` earlier in this branch, but it was the
only thing that had. `App` threw a TypeError on anything failing `isBaseAgent`,
and the agent loader filtered module exports with the same predicate -- so a
sample that exported a graph directly was not rejected with a useful message,
it simply was not found. Only one of the three doors a root can arrive through
was open, which is the least useful state to leave this in.

All three now normalize through one helper. `asRootAgent` moves out of the
runner and into `workflow_agent.ts`, next to the class it constructs, and
`isRootAgentLike` joins it for the loader, which has to *discover* a root among
a module's exports rather than being handed one. Wrapping stays a single
decision in a single place; the three call sites do not each grow their own.

Normalizing at the edge is what keeps this small. `App.rootAgent` and
`AgentLoader.agent` still hold a `BaseAgent` afterwards, so the dev server, the
graph renderer, the a2a card and the runner are all untouched -- including
`isGraphWorkflowAgent`, which google#654 uses to draw the graph and which still
matches because what it receives is still a `WorkflowAgent`.

Two messages widened to match: the loader's "no BaseAgent found" error, which
would otherwise send someone looking for the wrong thing, and the `--agent` CLI
help. The error `asRootAgent` throws is no longer phrased as the runner's,
since three callers now raise it.

* test(dev): load a real bundled Workflow root through the agent loader

The loader's unit test for a `Workflow` root mocks `esbuild.build` into a file
copy, so the interesting half of loading never happens. Real compilation uses
`packages: 'bundle'`, which inlines `@google/adk` into the compiled agent -- so
the `Workflow` the loader inspects comes from a *second*, minified copy of the
library, and its class object is not the loader's. That is precisely the case
the `Symbol.for('google.adk.workflow')` brand exists for, and the case the
mocked test cannot reach.

The gap is not theoretical: reverting `isWorkflow` to `instanceof Workflow` --
the review finding fixed in 09bff9f -- leaves all 37 unit tests passing while
four of these six fail with "expected [] to include 'graph'", the silent
not-found this PR set out to remove.

Nothing here is mocked: real `npm install`, real bundle, real dynamic import,
real `Runner`, and one case driving `adk run` as a child process, which is the
claim the PR actually makes -- that a sample can export a graph and be run.
The fixture graph is two function nodes, so it needs no model and its output is
exact. `lone_node` pins the other half: a node that is not a `Workflow` is
still not a root, and says so.

* docs(workflow): name the Workflow brand instead of linking it

This branch exports `isWorkflow`, which puts main's doc comment for it on
the public API surface, where its `{@link WORKFLOW_SIGNATURE_SYMBOL}`
resolves to a const that is deliberately not exported. `docs:check` runs
typedoc with --treatWarningsAsErrors, so that combination fails the
build even though neither branch fails it alone.

Spell the brand out instead. The guard is public and the brand is not,
so naming the string is also more use to a reader than a link they
cannot follow.

* test(dev): fold the Workflow root cases into the app loader suite

`tests/integration/agent_loader/` is about one narrow thing -- that `__dirname`,
`__filename` and `import.meta.url` survive compilation -- while everything about
*what a loaded entrypoint may export* already lives in
`tests/integration/app_loader/`: an `app.ts`, an `app.js`, a default export, and
a `discovery` project that mixes App directories, agent directories and
standalone files. A bare `Workflow` root is another entry in that same table, not
a suite of its own, so a reader comparing the accepted setups sees them together
and one `npm install` per fixture covers them all.

The cases are unchanged in substance, only rehomed:

- `app_workflow/` joins the CLI matrix, so `adk run` on an entrypoint exporting a
  graph is exercised next to the App entrypoints. Its answer node greets, which
  is the assertion the matrix already shares.
- `discovery/service_graph/` makes the graph a directory entrypoint alongside
  `service_alpha` (App) and `service_beta` (agent): discovery lists it, the
  compiled root adapts into a `WorkflowAgent`, it runs through a real `Runner`,
  and `loadApp()` synthesizes an App around it.
- `discovery/lone_node.ts` keeps the other half honest -- a node that is not a
  `Workflow` is still not a root, so it stays out of `listAgents()` and loading
  it by hand still throws.

Real compilation stays the point: nothing is mocked, so the `Workflow` the loader
inspects still comes from the second, bundled copy of `@google/adk` that the
brand check exists for.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…s for (google#694)

The graph tab has never drawn anything -- it shows "Agent structure graph
not available." because it calls two endpoints the TypeScript server does
not implement:

  404 GET /dev/apps/<app>/build_graph
  404 GET /dev/apps/<app>/build_graph_image?dark_mode=false

This is not the gap google#654 closed. That fixed the per-event graph, which is
a different route and still works; the tab simply asks elsewhere.

`build_graph` needs an app-info serializer, which TypeScript had nothing
like: an agent tree as JSON, with a workflow's structure read from its
`edges` rather than its (always empty) `subAgents` -- the same trap google#654
hit on the DOT side. `build_graph_image` returns one DOT per level, keyed
by path, so the UI can preload an app in a single request.

The levels are rendered by a new flat renderer rather than by
`agent_graph.ts`. That was the plan, but `agent_graph.ts` draws a nested
workflow as a recursive cluster with a qualified `parent.child` id, and
the UI binds its click handler to `g.node` elements, matching each one's
`<title>` against a bare child name. A cluster is never a `g.node`, so
every sub-workflow would have rendered and none would have been
clickable -- and drawing the whole tree at once leaves nothing to
navigate into anyway. So a level now draws its sub-workflows as single
nodes keyed by name, which is what makes drilling in work. adk-python
splits the same two jobs across `agent_graph.py` (per-event, recursive)
and `graph_visualization.py` (per-level, flat); this mirrors that split,
including its palettes, glyphs and START/END markers, so the picture
matches the legend the UI draws beside it.

One deliberate divergence from adk-python: the response carries `dotSrc`
for the requested level alongside the path-keyed map. The UI reads the
map when preloading every level, but reads `o.dotSrc` on the single-level
fetch it falls back to when a level is missing from that preload -- which
is exactly what happens two levels deep, because it re-keys a preloaded
`a/b` as `b`. Returning only the map, as adk-python does, leaves that
fallback blank. The extra key is ignored by the map reader.

Also registers the trace endpoints under the `/dev/apps/<app>/` prefix
the UI uses. Those were a prefix mismatch, not a missing feature: a trace
is keyed by event and session id alone, so both paths answer from the
same store.

Verified in a real browser against samples/workflows/routes: every sample
renders, loop_escalation shows its back-edge and route labels, clicking
workflow_B in nested_workflow navigates and re-renders that level, and
both themes draw. Out of scope and still 404ing: builder, eval_sets and
eval_results.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants