Show workflows in the subagent track (#2933)

* feat(server): show provider workflows in subagent track

Normalize workflow tasks into the provider-subagent contract so shared clients need no workflow awareness. Persisted nonterminal runs replay as failed because their original runtime can no longer deliver a terminal update.

* fix(server): preserve workflow replay and tool cards

* fix(server): suppress workflow notification cards

* fix(server): replay workflow child timelines

* fix(server): fail tasks when Claude exits

* fix(server): preserve workflow replay parity

* fix(server): fail tasks on Claude restart

* fix(server): hide workflow child prompts on replay

* fix: terminalize workflows on session reload

* fix(server): own workflow notification routing

* feat(server): show workflow results in detail pane

* fix(server): preserve unrestored task notifications

* fix(server): normalize replay task notifications
This commit is contained in:
Mohamed Boudra
2026-08-06 21:01:26 +08:00
committed by GitHub
parent c1f7ed3198
commit f4e8522050
20 changed files with 1686 additions and 57 deletions
@@ -0,0 +1,66 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
import { openSubagentsTrack } from "../support/helpers/subagents";
process.env.CLAUDE_CODE_WORKFLOWS = "1";
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/inspect-project.js");
const REPO_ROOT = path.resolve(__dirname, "../../../..");
const DESCRIPTION = "Inspect Paseo and explain what the project does";
test.describe("real Claude project-inspection workflow", () => {
test.setTimeout(300_000);
test("shows the workflow's actual report in the generic detail pane", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-project-inspection-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessage(
page,
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
WORKFLOW_SCRIPT,
)} and args ${JSON.stringify({ repoPath: REPO_ROOT })}. Wait for completion, then reply with exactly PROJECT_INSPECTION_DONE.`,
);
await expect(page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
await openSubagentsTrack(page);
const row = page
.getByTestId(/^subagents-track-row-/)
.filter({ has: page.getByText(DESCRIPTION, { exact: true }) });
await expect(row).toBeVisible({ timeout: 60_000 });
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
timeout: 180_000,
});
await expect(
page.getByTestId("assistant-message").filter({ hasText: "PROJECT_INSPECTION_DONE" }).last(),
).toBeVisible({ timeout: 180_000 });
await row.click();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
for (const heading of [
"What Paseo is",
"Who it is for",
"How it works",
"Repository structure",
]) {
await expect(panel.getByText(heading, { exact: true })).toBeVisible();
}
await expect(panel.getByText("export const meta", { exact: false })).toHaveCount(0);
await expect(
panel.getByText("Inspect the Paseo repository at", { exact: false }),
).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("project-workflow-pane.png") });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,55 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "../support/fixtures";
import {
askClaudeToRunWorkflow,
expectSingleWorkflowParentCard,
expectWorkflowCompleted,
expectWorkflowRunning,
openWorkflowTimeline,
releaseWorkflow,
} from "../support/helpers/claude-workflow";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
// Claude Code currently gates the Workflow tool behind its own rollout. The real-provider test
// opts in explicitly so it tests Paseo's integration rather than the account's rollout cohort.
process.env.CLAUDE_CODE_WORKFLOWS = "1";
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/one-child.js");
test.describe("real Claude workflow subagent row", () => {
test.setTimeout(300_000);
test("shows a real Claude workflow running and completed through the generic subagent UI", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-workflow-row-")));
const gatePath = path.join(cwd, "release-workflow");
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await test.step("ask Claude to run the workflow", async () => {
await askClaudeToRunWorkflow(handle!, WORKFLOW_SCRIPT, gatePath);
await expectWorkflowRunning(page);
await page.screenshot({ path: testInfo.outputPath("workflow-running.png") });
releaseWorkflow(gatePath);
});
await test.step("see the workflow finish without leaving a running row", async () => {
await expectWorkflowCompleted(page);
await expectSingleWorkflowParentCard(page);
await page.screenshot({ path: testInfo.outputPath("workflow-completed.png") });
});
await test.step("open the workflow through the existing provider-subagent pane", async () => {
await openWorkflowTimeline(page);
await page.screenshot({ path: testInfo.outputPath("workflow-timeline.png") });
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,32 @@
export const meta = {
name: "paseo-project-inspection",
description: "Inspect Paseo and explain what the project does",
whenToUse: "Paseo real-provider workflow detail QA",
phases: [{ title: "Inspect", detail: "Read the product and architecture sources" }],
};
phase("Inspect");
let input = args;
if (typeof input === "string") input = JSON.parse(input);
const repoPath = input?.repoPath;
if (typeof repoPath !== "string" || repoPath.length === 0) {
throw new Error("repoPath is required");
}
const report = await agent(
`Inspect the Paseo repository at ${JSON.stringify(repoPath)}.
Read README.md, docs/product.md, docs/architecture.md, and package.json. Do not edit anything. Return a concise plain-text report with exactly these headings:
What Paseo is
Who it is for
How it works
Repository structure
Under each heading, give 1-3 concrete sentences grounded in those files. Mention the supported coding agents and the local-code/privacy model.`,
{
label: "project-inspector",
phase: "Inspect",
},
);
return { report };
@@ -0,0 +1,30 @@
export const meta = {
name: "paseo-workflow-row-qa",
description: "Verify the workflow row lifecycle",
whenToUse: "Paseo real-provider QA only",
phases: [{ title: "Verify", detail: "one child returns a fixed marker" }],
};
phase("Verify");
let input = args;
if (typeof input === "string") input = JSON.parse(input);
const gatePath = input?.gatePath;
if (typeof gatePath !== "string" || gatePath.length === 0) {
throw new Error("gatePath is required");
}
const child = await agent(
`First use Bash to run this command and wait for it to exit:
while [ ! -f ${JSON.stringify(gatePath)} ]; do sleep 0.2; done
Then return exactly this JSON object and do nothing else: {"marker":"PASEO_WORKFLOW_ROW_OK"}`,
{
label: "workflow-row-child",
phase: "Verify",
schema: {
type: "object",
required: ["marker"],
properties: { marker: { type: "string" } },
},
},
);
return { child };
@@ -0,0 +1,75 @@
import { writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { submitMessage } from "./composer";
import type { AgentHandle } from "./rewind-flow";
import { openSubagentsTrack } from "./subagents";
export const WORKFLOW_ROW_DESCRIPTION = "Verify the workflow row lifecycle";
export const WORKFLOW_ROW_MARKER = "PASEO_WORKFLOW_ROW_OK";
export async function askClaudeToRunWorkflow(
handle: AgentHandle,
scriptPath: string,
gatePath: string,
): Promise<void> {
await submitMessage(
handle.page,
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
scriptPath,
)} and args ${JSON.stringify({ gatePath })}. Wait for its completion notification, then reply with exactly ${WORKFLOW_ROW_MARKER}.`,
);
await expect(handle.page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
await openSubagentsTrack(handle.page);
}
export function releaseWorkflow(gatePath: string): void {
writeFileSync(gatePath, "release\n", "utf8");
}
export function workflowRow(page: Page) {
// The parent transcript's Workflow tool card has the same accessible name. The track has no
// separate landmark, so its established row test id is the only unambiguous scope.
return page
.getByTestId(/^subagents-track-row-/)
.filter({ has: page.getByText(WORKFLOW_ROW_DESCRIPTION, { exact: true }) });
}
export async function expectWorkflowRunning(page: Page): Promise<void> {
const row = workflowRow(page);
await expect(row).toBeVisible({ timeout: 60_000 });
await expect(row).toContainText(WORKFLOW_ROW_DESCRIPTION);
await expect(row).toContainText(/Workflow(?: · .+)?/);
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
}
export async function expectWorkflowCompleted(page: Page): Promise<void> {
const row = workflowRow(page);
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
timeout: 120_000,
});
await expect(page.getByTestId("subagents-track-archive-finished")).toBeVisible();
await expect(
page.getByTestId("assistant-message").filter({ hasText: WORKFLOW_ROW_MARKER }).last(),
).toBeVisible({ timeout: 120_000 });
}
export async function expectSingleWorkflowParentCard(page: Page): Promise<void> {
const parentToolCards = page.getByTestId("tool-call-badge");
await expect(
parentToolCards.filter({ has: page.getByText("Workflow", { exact: true }) }),
).toHaveCount(1);
await expect(parentToolCards.filter({ hasText: "Task Notification" })).toHaveCount(0);
}
export async function openWorkflowTimeline(page: Page): Promise<void> {
await workflowRow(page).click();
const panel = page.getByTestId("provider-subagent-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
/Workflow(?: · .+)?/,
);
await expect(panel.getByText("Start chatting with this agent...", { exact: true })).toHaveCount(
0,
);
await expect(panel.getByText(WORKFLOW_ROW_MARKER, { exact: true })).toBeVisible();
}