Compare commits

...
Author SHA1 Message Date
b62af7287b docs(qa): retain native question reproduction evidence
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
2026-10-17 07:23:14 +02:00
paseo-bot[bot] 4ce6704d5e test(acp): reproduce unanswered native question requests 2026-10-17 07:10:58 +02:00
paseo-bot[bot]andboeto f472c9d1f4 Correct workspace source parameters in the Paseo skill (#7188)
* docs(skills): align workspace source parameters

* docs(skills): clarify workspace source inputs

* test(skills): check workspace guidance against MCP inputs

Keep the bundled workspace guidance aligned with the tool schema agents receive, so removed source parameters do not remain in the instructions.

Co-authored-by: boeto <bo@eto.me>

---------

Co-authored-by: boeto <bo@eto.me>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 06:47:31 +02:00
Mohamed Boudra ec9817ed27 fix(app): make home actions reachable on short screens (#7167)
* fix(app): make home actions reachable on short screens

Keep the home cards and community links in one scrollable flow so overflow stays reachable and the footer cannot overlap the cards.

* fix(app): keep home scrolling clear of window drag regions
2026-10-17 11:29:58 +07:00
Mohamed Boudra 8555e8abd9 fix(release): distribute stable iOS builds to TestFlight testers (#7189)
Replace the upload-only submission with the existing TestFlight distribution job shape so external testers receive stable releases. Keep App Store review targeting the same version and build without a second binary upload.
2026-10-17 11:02:23 +07:00
paseo-bot[bot] 88a7aec1b8 fix(claude): keep active turns running through settings changes (#7179)
A pending thinking change retired the live query when mode, model, or command controls requested it. Reuse the query owned by the current turn and apply pending launch settings after the turn finishes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 03:54:00 +02:00
paseo-bot[bot]andHung Le cd77ecd837 Start idle OMP agents when they receive follow-ups (#7169)
* fix(server): run slash commands sent by another agent out of band again

* fix(omp): send /steer and /follow-up as prompts so an idle agent starts a run

* test(omp): verify idle follow-ups and parent notifications

Cover real daemon and MCP delivery, and keep the fake runtime tests independent of an installed OMP executable.

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>

* fix(omp): report idle follow-up admission failures

Use the existing prompt lifecycle for an idle command so acknowledgements and late rejections settle the child and notify its caller. Keep busy commands on the streaming prompt path, and accept one runtime prompt input object.

---------

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 01:10:35 +02:00
paseo-bot[bot] 30122b1ae1 Show a background Claude subagent's action log on its card (#6242)
* fix(claude): show a background subagent's action log on its card

Claude Code answers a background Agent call at launch and streams the
child's frames afterwards. Since #6067 the sidechain tracker stopped
emitting the parent card once the call had its result, so the card's
log stayed empty. The tracker now rebuilds the card from the settled
call, keeping its status, output, and label, and adds the child's
action log.

Closes #6157

* fix(claude): release a settled subagent call when the child finishes

* fix(claude): keep a background subagent's card in the turn that settled it

A background child often keeps streaming after the parent's turn ends. Its
frames then rebuilt the settled card in the next turn with an empty log, and
the projection showed it as a second card for the same call. The settled call
now rebuilds the card only within the turn that answered it.

* test(claude): model the turn boundary in background card coverage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-16 23:53:46 +02:00
xzthemeofsss d152f1529f fix(app): recover failed question responses (#7098)
* test: reproduce failed question response recovery

* fix: show question response failures and allow retry
2026-10-16 22:12:05 +02:00
paseo-ai[bot] 05b3745dcc fix: update lockfile signatures and Nix hash [skip ci] 2026-10-16 19:10:33 +00:00
42 changed files with 1492 additions and 161 deletions
+3 -3
View File
@@ -205,12 +205,12 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
@@ -0,0 +1,27 @@
# Native Grok question reproduction
This branch retains an intentionally failing regression for [#3490](https://github.com/getpaseo/paseo/issues/3490). It changes no production behavior.
Run from the repository root after installing dependencies and building server dependencies:
```sh
npm ci --no-audit --no-fund
npm run build:server-deps
npm run test:unit --workspace=@getpaseo/server -- src/server/agent/providers/grok-question-repro.test.ts
```
On main `f472c9d1f4339c0f36583e84bf62c80c12867110`, the test fails:
```text
Question permissions: 0 (expected 1)
Expected: {"outcome":"accepted","answers":{"Choose a color":["Blue"]}}
Received: {"code":-32601,"message":"\"Method not found\": _x.ai/ask_user_question"}
```
The test uses a deterministic subprocess with the real ACP SDK and stdio transport, through the public session API. It emits the native request, chooses Blue if a question permission arrives, and checks the response. It does not use a live model or render the app.
The same test passes unchanged on [#7015](https://github.com/getpaseo/paseo/pull/7015) at `b8a48e4825cf49d09f352a4b3d7793847657908c`. All 165 targeted local checks passed there: 146 ACP tests including this independent regression, 10 shared form-core tests, two serialization tests, and seven Chromium question-form tests.
The real Grok attempt ended during session initialization, before any question was emitted. Live question generation, the rendered card, lettered fallback, OpenCode comparison, and the contributor's newer report of Grok merging labels after answering remain unverified. Reproduction confidence is 3/5; diagnosis confidence is 5/5 for the reproduced protocol failure only.
The missing-handler analysis from [luoph](https://github.com/luoph) holds at the ACP boundary. The proposed fix and subsequent answer-identity work are by luoph and [RoundRedDot](https://github.com/RRD-dev); this branch retains independent verification rather than a replacement fix. Updates stay on [the issue](https://github.com/getpaseo/paseo/issues/3490).
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+13 -10
View File
@@ -305,12 +305,14 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
@@ -334,7 +336,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# Inspect the full release workflow, including distribute_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
@@ -347,17 +349,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
@@ -380,8 +382,9 @@ Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
For stable releases, also confirm every required mobile build, external
TestFlight distribution, Beta App Review submission, and store submission job
for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
@@ -396,7 +399,7 @@ Pattern:
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
@@ -680,7 +683,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
+1 -1
View File
@@ -1 +1 @@
sha256-Sw9WSOnvebpXgty0WACJrRq+pkKuynLMxJg6D/SnyRk=
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
@@ -23,13 +23,16 @@ jobs:
platform: android
profile: production
submit_ios:
name: Submit iOS
distribute_ios:
name: Distribute iOS to TestFlight
needs: [build_ios]
type: submit
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -41,7 +44,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, submit_ios]
needs: [build_ios, distribute_ios]
environment: production
runs_on: macos-medium
steps:
@@ -0,0 +1,42 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
async function openProjectHome(page: Page) {
await page.goto("/open-project");
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
}
async function scrollToHomeFooter(page: Page) {
await page.mouse.move(160, 350);
await page.mouse.wheel(0, 1500);
}
async function expectHomeActionsReachable(page: Page) {
const providers = page.getByTestId("open-project-setup-providers");
const community = page.getByRole("button", { name: "Community", exact: true });
await expect(providers).toBeInViewport({ ratio: 1 });
await expect(community).toBeInViewport({ ratio: 1 });
const providerBounds = await providers.boundingBox();
const communityBounds = await community.boundingBox();
expect(providerBounds).not.toBeNull();
expect(communityBounds).not.toBeNull();
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
}
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 320, height: 568 });
await openProjectHome(page);
await scrollToHomeFooter(page);
await expectHomeActionsReachable(page);
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
});
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openProjectHome(page);
await expectHomeActionsReachable(page);
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
"-webkit-app-region",
"none",
);
});
@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
+6
View File
@@ -1531,11 +1531,17 @@ function PermissionRequestCard({
);
if (request.kind === "question") {
const responseFailed =
permissionMutation.isError &&
permissionMutation.variables?.agentId === permission.agentId &&
permissionMutation.variables?.requestId === permission.request.id;
const responseError = responseFailed ? t("message.question.responseFailed") : null;
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
responseError={responseError}
/>
);
}
@@ -58,6 +58,7 @@ function mountCard(question: Record<string, unknown>) {
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
responseError={null}
/>,
),
);
@@ -1,3 +1,4 @@
import { Alert } from "@/components/ui/alert";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
@@ -27,6 +28,7 @@ interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
responseError: string | null;
}
const IS_WEB = isWeb;
@@ -339,7 +341,12 @@ function QuestionOtherInput({
);
}
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
export function QuestionFormCard({
permission,
onRespond,
isResponding,
responseError,
}: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -438,7 +445,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
]);
const handleDeny = useCallback(() => {
if (!questions) return;
if (!questions || isResponding) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({
@@ -454,7 +461,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
behavior: "deny",
message: "Dismissed by user",
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
}, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
@@ -487,6 +494,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
);
const pendingAction = isResponding ? respondingAction : null;
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion
? t("message.question.submit")
@@ -550,7 +558,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]);
return (
<View style={containerStyle} testID="question-form-card">
@@ -604,6 +612,8 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
</View>
) : null}
{responseError ? <Alert variant="error" description={responseError} size="sm" /> : null}
<View style={actionsContainerStyle}>
<Pressable
style={dismissButtonStyle}
@@ -613,7 +623,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
{pendingAction === "dismiss" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
@@ -631,7 +641,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={primaryActionLabel}
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
{pendingAction === "submit" ? (
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
+1
View File
@@ -369,6 +369,7 @@ export const ar: TranslationResources = {
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
submit: "يُقدِّم",
next: "التالي",
answerPlaceholder: "اكتب إجابتك...",
+1
View File
@@ -366,6 +366,7 @@ export const en = {
failedRetry: "Dictation failed. Tap retry.",
},
question: {
responseFailed: "Could not send your response. Try again.",
submit: "Submit",
next: "Next",
answerPlaceholder: "Type your answer...",
+1
View File
@@ -374,6 +374,7 @@ export const es: TranslationResources = {
failedRetry: "El dictado falló. Toca reintentar.",
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
submit: "Entregar",
next: "Siguiente",
answerPlaceholder: "Escribe tu respuesta...",
+1
View File
@@ -374,6 +374,7 @@ export const fr: TranslationResources = {
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
submit: "Envoyer",
next: "Suivant",
answerPlaceholder: "Saisissez votre réponse…",
+1
View File
@@ -374,6 +374,7 @@ export const ja: TranslationResources = {
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
submit: "送信",
next: "次へ",
answerPlaceholder: "回答を入力...",
+1
View File
@@ -370,6 +370,7 @@ export const ko: TranslationResources = {
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
submit: "제출",
next: "다음",
answerPlaceholder: "답변을 입력하세요...",
+1
View File
@@ -373,6 +373,7 @@ export const ptBR: TranslationResources = {
failedRetry: "Falha no ditado. Toque para tentar novamente.",
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
submit: "Enviar",
next: "Próximo",
answerPlaceholder: "Digite sua resposta...",
+1
View File
@@ -373,6 +373,7 @@ export const ru: TranslationResources = {
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
submit: "Отправить",
next: "Далее",
answerPlaceholder: "Введите ответ...",
+1
View File
@@ -369,6 +369,7 @@ export const zhCN: TranslationResources = {
failedRetry: "听写失败。点按重试。",
},
question: {
responseFailed: "未能发送你的回复,请重试。",
submit: "提交",
next: "下一步",
answerPlaceholder: "输入你的回答...",
@@ -1,7 +1,7 @@
import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable } from "react-native";
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
@@ -18,11 +18,12 @@ import {
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
const WINDOW_CONTENT_DATASET = { "window-content": "" };
export function OpenProjectScreen() {
const { t } = useTranslation();
const hosts = useHosts();
@@ -61,51 +62,56 @@ export function OpenProjectScreen() {
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
{hosts.map((host) => (
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
))}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title={t("openProject.tiles.addProject.title")}
description={t("openProject.tiles.addProject.description")}
onPress={handleOpenPicker}
testID="open-project-submit"
accent
/>
<HomeTile
icon={Inbox}
title={t("openProject.tiles.importSession.title")}
description={t("openProject.tiles.importSession.description")}
onPress={importSession.open}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title={t("openProject.tiles.setupProviders.title")}
description={t("openProject.tiles.setupProviders.description")}
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{localServerId ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
dataSet={WINDOW_CONTENT_DATASET}
>
<View style={styles.content}>
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
{hosts.map((host) => (
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
))}
<View style={styles.tiles}>
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
icon={FolderOpen}
title={t("openProject.tiles.addProject.title")}
description={t("openProject.tiles.addProject.description")}
onPress={handleOpenPicker}
testID="open-project-submit"
accent
/>
) : null}
<HomeTile
icon={Inbox}
title={t("openProject.tiles.importSession.title")}
description={t("openProject.tiles.importSession.description")}
onPress={importSession.open}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title={t("openProject.tiles.setupProviders.title")}
description={t("openProject.tiles.setupProviders.description")}
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
/>
) : null}
</View>
</View>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
</ScrollView>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
@@ -176,18 +182,21 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
scroll: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
content: {
position: "relative",
flex: 1,
flexGrow: 1,
flexShrink: 0,
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
logo: {
marginBottom: theme.spacing[8],
@@ -238,10 +247,8 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
},
communityRow: {
position: "absolute",
left: 0,
right: 0,
bottom: {
flexShrink: 0,
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
md: HEADER_INNER_HEIGHT + theme.spacing[2],
},
@@ -10929,6 +10929,41 @@ test("authoritative timeline records a daemon-handled submitted prompt before it
}
});
test("tryRunOutOfBand gives providers the command another agent sent, not its envelope", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-agent-out-of-band-"));
const seenPrompts: AgentPromptInput[] = [];
class SlashCommandSession extends TestAgentSession {
override tryHandleOutOfBand(prompt: AgentPromptInput) {
seenPrompts.push(prompt);
return prompt === "/handled" ? { run: async () => {} } : null;
}
}
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new SlashCommandSession(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(seenPrompts).toEqual(["/handled"]);
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
const pasted = prepareAgentMessage(String(fromAgent.prompt));
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-"));
const storagePath = join(workdir, "agents");
@@ -79,7 +79,7 @@ import {
type PendingForegroundRun,
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { projectAgentMessage } from "./agent-messages/index.js";
import { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
@@ -2459,7 +2459,12 @@ export class AgentManager {
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
const agent = this.requireSessionAgent(agentId);
const handler = agent.session.tryHandleOutOfBand?.(prompt);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
const handler = agent.session.tryHandleOutOfBand?.(
agentMessage?.source ? agentMessage.text : prompt,
);
if (!handler) {
return false;
}
@@ -5,7 +5,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -15,6 +15,8 @@ import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { OmpAgentClient } from "./providers/omp/agent.js";
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
import type {
AgentClient,
AgentMode,
@@ -841,6 +843,49 @@ function createAgentMcpServer(options: Parameters<typeof createAgentMcpServerWit
});
}
describe("bundled Paseo skill", () => {
it("documents only advertised create_workspace input names", async () => {
const server = await createAgentMcpServer({
agentManager: new BoundaryAgentManagerFake() as AgentManager,
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
logger: createTestLogger(),
});
const client = await connectInMemoryMcpClient(server);
try {
const skill = await readFile(
new URL("../../../../../skills/paseo/SKILL.md", import.meta.url),
"utf8",
);
const paragraph = skill.split("\n").find((line) => line.startsWith("**`create_workspace`**"));
expect(paragraph).toBeTypeOf("string");
// The final sentence describes output, rather than tool inputs.
const inputGuidance = paragraph!.split("Returns ")[0]!;
const documentedInputs = [...inputGuidance.matchAll(/`([a-zA-Z][a-zA-Z0-9]*)`/g)].map(
(match) => match[1]!,
);
expect(documentedInputs.length).toBeGreaterThan(0);
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "create_workspace")!;
const properties = tool.inputSchema.properties!;
const advertisedNamesAndValues = new Set([
...Object.keys(properties),
...Object.values(properties).flatMap((property) =>
typeof property === "object" && property !== null && Array.isArray(property.enum)
? property.enum
: [],
),
]);
expect(documentedInputs.filter((name) => !advertisedNamesAndValues.has(name))).toEqual([]);
} finally {
await client.close();
await server.close();
}
});
});
describe("browser MCP tools", () => {
const logger = createTestLogger();
@@ -3856,8 +3901,7 @@ class HeldTurnAgentSession implements AgentSession {
}
tryHandleOutOfBand(prompt: AgentPromptInput) {
const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null;
return text === "/side-command" ? { run: async () => {} } : null;
return prompt === "/side-command" ? { run: async () => {} } : null;
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
@@ -4557,6 +4601,180 @@ describe("send_agent_prompt MCP tool", () => {
});
});
describe("send_agent_prompt MCP tool to an OMP child", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
async function setupOmpChild() {
const workdir = await mkdtemp(join(tmpdir(), "mcp-omp-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const omp = new FakeOmp();
const agentManager = new AgentManager({
clients: {
claude: parentClient,
omp: new OmpAgentClient({
logger,
runtime: omp,
noTurnScheduler: { waitForSettle: async () => {} },
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
}),
},
registry: storage,
logger,
});
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await agentManager.createAgent(
{ provider: "omp", cwd: existingCwd, title: "OMP child" },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
return {
agentManager,
storage,
workdir,
parentClient,
ompSession: omp.latestSession(),
childId: child.id,
tool: registeredTool(server, "send_agent_prompt"),
};
}
async function expectOneFinishNotification(
agentManager: AgentManager,
parentClient: HeldTurnAgentClient,
childId: string,
) {
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("idle"));
await vi.waitFor(() => {
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts[0]).toContain(
`Agent ${childId} (OMP child) finished.`,
);
});
}
it.each([
{ command: "follow-up", streamingBehavior: "followUp" },
{ command: "steer", streamingBehavior: "steer" },
] as const)(
"delivers /$command into a turn OMP started on its own, without aborting it",
async ({ command, streamingBehavior }) => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
ompSession.beginTurn();
ompSession.streamAssistantText("background job finished, working on it");
await vi.waitFor(() => {
expect(agentManager.getAgent(childId)?.activeTurnId).toMatch(/^autonomous-/);
});
const response = await invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: `/${command} then summarize the logs`,
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
status: "running",
});
await vi.waitFor(() => {
expect(ompSession.prompts).toEqual([
{ message: "then summarize the logs", imageCount: 0, streamingBehavior },
]);
});
expect(ompSession.abortRequested).toBe(false);
expect(agentManager.getAgent(childId)?.lifecycle).toBe("running");
ompSession.finishTurn();
await expectOneFinishNotification(agentManager, parentClient, childId);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it.each(["before ack", "after ack"])(
"reports an idle follow-up rejected %s and notifies its parent",
async (order) => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
ompSession.promptAck = { requestId: "follow-up-rejected" };
const rejection = {
type: "prompt_result",
id: "follow-up-rejected",
agentInvoked: false,
status: "error",
sessionSettled: true,
error: { message: "Provider rejected the follow-up", retryable: false },
} as const;
const promptStarted = ompSession.nextPrompt();
const response = invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: "/follow-up check the build",
});
await promptStarted;
if (order === "before ack") ompSession.emit(rejection);
await response;
if (order === "after ack") ompSession.emit(rejection);
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("error"));
expect(agentManager.getAgent(childId)?.lastError).toContain(
"Provider rejected the follow-up",
);
await vi.waitFor(() => expect(parentClient.sessions[0]!.prompts).toHaveLength(1));
expect(parentClient.sessions[0]!.prompts[0]).toContain(
`Agent ${childId} (OMP child) errored.`,
);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it("lets an idle child start a run with the follow-up", async () => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
const response = await invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: "/follow-up check the build",
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
status: "running",
});
await vi.waitFor(() => {
expect(ompSession.prompts).toEqual([
{ message: "check the build", imageCount: 0, streamingBehavior: "followUp" },
]);
});
ompSession.beginTurn();
ompSession.streamAssistantText("Build is green");
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
ompSession.finishTurn();
await expectOneFinishNotification(agentManager, parentClient, childId);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
});
});
describe("update_agent MCP tool", () => {
const logger = createTestLogger();
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentTimelineRow } from "../../agent-manager.js";
import { projectTimelineRows } from "../../timeline-projection.js";
import { ClaudeAgentClient } from "./agent.js";
@@ -105,6 +105,39 @@ async function collectUntilTerminal(
return events;
}
async function collectTimelineAcrossTurns(
session: AgentSession,
prompt: string,
turnCount: number,
): Promise<AgentTimelineRow[]> {
const rows: AgentTimelineRow[] = [];
let currentTurnId: string | undefined;
let completedTurns = 0;
let unsubscribe = () => {};
const turnsCompleted = new Promise<void>((resolve) => {
unsubscribe = session.subscribe((event) => {
if (event.type === "turn_started") currentTurnId = event.turnId;
if (event.type === "timeline") {
rows.push({
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
});
});
try {
await session.startTurn(prompt);
await turnsCompleted;
return rows;
} finally {
unsubscribe();
await session.close();
}
}
describe("background Claude subagents", () => {
afterEach(() => {
queryFactory.mockReset();
@@ -431,6 +464,124 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
},
},
],
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
@@ -0,0 +1,157 @@
import { expect, test } from "vitest";
import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
// The injected SDK adapter consumes real session input and holds a command open
// until the test releases it. Closing the query interrupts that command.
class ForegroundQuery {
readonly work = deferred<void>();
readonly closed = deferred<void>();
private stopped = false;
private readonly output: AsyncGenerator<SDKMessage>;
constructor(input: ClaudeQueryInput) {
this.output = this.events(input);
}
private async *events(input: ClaudeQueryInput): AsyncGenerator<SDKMessage> {
if (typeof input.prompt === "string") throw new Error("Expected streaming input");
for await (const _prompt of input.prompt) {
yield {
type: "system",
subtype: "init",
session_id: "settings-session",
model: "claude-haiku-5-5",
permissionMode: "auto",
} as SDKMessage;
yield {
type: "assistant",
message: {
content: [
{ type: "tool_use", id: "tool_sleep", name: "Bash", input: { command: "sleep 20" } },
],
},
} as SDKMessage;
await this.work.promise;
if (this.stopped) return;
yield { type: "assistant", message: { content: "DONE" } } as SDKMessage;
yield {
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
} as SDKMessage;
}
}
next() {
return this.output.next();
}
return() {
this.close();
return this.output.return();
}
close() {
this.stopped = true;
this.closed.resolve();
this.work.resolve();
}
async interrupt() {
this.close();
}
async setPermissionMode() {}
async setModel() {}
async supportedCommands() {
return [];
}
[Symbol.asyncIterator]() {
return this;
}
}
test.each(["mode", "model", "commands"] as const)(
"a pending thinking change preserves the current turn during a %s request",
async (control) => {
const queries: ForegroundQuery[] = [];
const efforts: ClaudeQueryInput["options"]["effort"][] = [];
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude",
queryFactory: (input) => {
const query = new ForegroundQuery(input);
queries.push(query);
efforts.push(input.options.effort);
return query as unknown as Query;
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
modeId: "auto",
thinkingOptionId: "low",
});
const running = deferred<void>();
let terminal = deferred<string>();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => {
events.push(event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "running"
)
running.resolve();
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
)
terminal.resolve(event.type);
});
try {
await session.startTurn("Run the foreground command");
await running.promise;
expect(await session.setThinkingOption?.("medium")).toMatchObject({
message: "Thinking level applies next turn",
});
if (control === "mode") await session.setMode("acceptEdits");
else if (control === "model") await session.setModel("claude-sonnet-5-6");
else await session.listCommands();
queries[0].work.resolve();
expect(
await Promise.race([
terminal.promise,
queries[0].closed.promise.then(() => "query closed before turn finished"),
]),
).toBe("turn_completed");
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text === "DONE",
),
).toBe(true);
terminal = deferred<string>();
await session.startTurn("Run another command with the new thinking level");
queries[1].work.resolve();
expect(await terminal.promise).toBe("turn_completed");
expect(efforts).toEqual(["low", "medium"]);
} finally {
await session.close();
}
},
);
@@ -2228,18 +2228,37 @@ class ClaudeAgentSession implements AgentSession {
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
readWorkflowResult: readClaudeWorkflowResultFile,
});
/**
* Subagent tool calls Claude has already answered while the child still runs, with the input
* they were launched with and the turn that answered them. A background child is answered at
* launch and streams its frames afterwards.
*/
private readonly settledSubagentToolCalls = new Map<
string,
{
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
input: AgentMetadata | null;
turnId: string | null;
}
>();
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
// strings: it reacts to what this session actually does.
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// The parent's tool call owns its card once its result arrives. A background child streams
// its frames after Claude has already answered the call, and re-emitting the card from them
// would replace the settled card with an unlabeled, running one.
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
needsSyntheticParentToolCard: (toolUseId) =>
this.toolUseCache.has(toolUseId) &&
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -2591,13 +2610,13 @@ class ClaudeAgentSession implements AgentSession {
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const previousQuery = this.query;
const activeQuery = await this.ensureQuery(normalized);
try {
await activeQuery.setPermissionMode(normalized);
} catch (error) {
// The query was launched in the rejected mode; relaunch in the current mode next time.
if (launchesQuery) this.queryRestartNeeded = true;
if (activeQuery !== previousQuery) this.queryRestartNeeded = true;
throw error;
}
if (normalized === "plan") {
@@ -2876,6 +2895,7 @@ class ClaudeAgentSession implements AgentSession {
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.sidechainTracker.clear();
this.settledSubagentToolCalls.clear();
this.taskProtocolSource.reset();
this.input?.end();
this.query?.close?.();
@@ -3294,7 +3314,9 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
return this.query;
}
@@ -4361,6 +4383,7 @@ class ClaudeAgentSession implements AgentSession {
for (const event of foldSubagentObservations(subagentObservations)) {
events.push({ type: "provider_subagent", provider: "claude", event });
}
this.releaseFinishedSubagentToolCalls(subagentObservations);
for (const observation of subagentObservations) {
if (observation.kind !== "declared") continue;
if (!this.taskProtocolSource.needsSyntheticParentToolCard(observation.id)) continue;
@@ -5548,28 +5571,22 @@ class ClaudeAgentSession implements AgentSession {
const { images, text } = splitClaudeToolResultImages(block.content);
const output = this.buildToolOutput(text, block, entry);
if (block.is_error) {
this.pushToolCall(
mapClaudeFailedToolCall({
const settledToolCall = block.is_error
? mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: { ...block, content: text },
}),
items,
);
} else {
this.pushToolCall(
mapClaudeCompletedToolCall({
})
: mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
}),
items,
);
}
});
this.pushToolCall(settledToolCall, items);
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
@@ -5585,6 +5602,34 @@ class ClaudeAgentSession implements AgentSession {
}
}
private rememberSettledSubagentToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
entry: ToolUseCacheEntry | undefined,
): void {
const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined;
if (!item || !subagentId) return;
this.settledSubagentToolCalls.set(subagentId, {
item,
input: entry?.input ?? null,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null,
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
}
}
}
private buildToolOutput(
content: unknown,
block: ClaudeContentChunk,
@@ -18,6 +18,8 @@ interface ClaudeContentChunk {
[key: string]: unknown;
}
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentActionEntry {
index: number;
toolName: string;
@@ -64,6 +66,7 @@ export class ClaudeSidechainTracker {
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
private readonly isDescriptorOwnedElsewhere: () => boolean;
private readonly needsSyntheticParentToolCard: (toolUseId: string) => boolean;
private readonly getSettledParentToolCall: (toolUseId: string) => ClaudeToolCallItem | undefined;
constructor(input: {
getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
@@ -74,10 +77,17 @@ export class ClaudeSidechainTracker {
*/
isDescriptorOwnedElsewhere?: () => boolean;
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
this.getToolInput = input.getToolInput;
this.isDescriptorOwnedElsewhere = input.isDescriptorOwnedElsewhere ?? (() => false);
this.needsSyntheticParentToolCard = input.needsSyntheticParentToolCard ?? (() => true);
this.getSettledParentToolCall = input.getSettledParentToolCall ?? (() => undefined);
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
@@ -119,17 +129,19 @@ export class ClaudeSidechainTracker {
return [];
}
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
});
const toolCall =
this.getSettledParentToolCall(parentToolUseId) ??
mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
});
if (!toolCall) {
return [];
}
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
const detail: ClaudeToolCallItem["detail"] = {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
@@ -0,0 +1,94 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { ACPAgentSession } from "./acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
// A deterministic ACP agent emits the native request captured in issue #3490.
// This exercises the public session API and SDK transport, without a live model.
const agentSource = `
import { Readable, Writable } from 'node:stream';
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
new AgentSideConnection(connection => ({
async initialize() { return {protocolVersion:PROTOCOL_VERSION}; },
async newSession() { return {sessionId:'question-repro'}; },
async authenticate() {}, async cancel() {},
async prompt() {
let result;
try {
result = await connection.extMethod('_x.ai/ask_user_question', {
sessionId:'question-repro', toolCallId:'question-1', mode:'default',
questions:[{question:'Choose a color',options:[{label:'Red'},{label:'Blue'}],multiSelect:false}]
});
} catch(error) { result = {code:error.code,message:error.message}; }
await connection.sessionUpdate({sessionId:'question-repro',update:{
sessionUpdate:'agent_message_chunk', content:{type:'text',text:JSON.stringify(result)}
}});
return {stopReason:'end_turn'};
}
}), ndJsonStream(Writable.toWeb(process.stdout),Readable.toWeb(process.stdin)));
`;
test("native Grok question reaches the permission interface and accepts the selected answer", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-grok-question-"));
const script = path.join(directory, "agent.mjs");
await writeFile(script, agentSource);
const session = new ACPAgentSession(
{ provider: "grok", cwd: directory },
{
provider: "grok",
logger: createTestLogger(),
defaultCommand: [process.execPath, script],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
launchEnv: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
},
},
);
const questions: unknown[] = [];
const replies: string[] = [];
let finish!: () => void;
const done = new Promise<void>((resolve) => {
finish = resolve;
});
session.subscribe((event) => {
if (event.type === "permission_requested") {
questions.push(event.request);
void session.respondToPermission(event.request.id, {
behavior: "allow",
updatedInput: {
questionAnswers: { "Question 1": { selectedOptionIds: ["1"] } },
},
});
}
if (event.type === "timeline" && event.item.type === "assistant_message")
replies.push(event.item.text);
if (event.type === "turn_completed" || event.type === "turn_failed") finish();
});
try {
await session.initializeNewSession();
await session.startTurn("Ask me to choose a color.");
await done;
expect.soft(questions).toHaveLength(1);
expect(replies.join("")).toBe(
JSON.stringify({ outcome: "accepted", answers: { "Choose a color": ["Blue"] } }),
);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
await rm(directory, { recursive: true, force: true });
}
});
@@ -39,6 +39,19 @@ const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = {
errorMessage: "Interrupted by user",
};
test("an idle follow-up is submitted as a prompt so OMP can start a turn", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const handler = session.tryHandleOutOfBand?.("/follow-up check the build");
expect(handler).not.toBeNull();
await handler!.run({ emit: () => {} });
expect(runtime.latestSession().prompts).toEqual([
{ message: "check the build", imageCount: 0, streamingBehavior: "followUp" },
]);
});
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({
readyTimeoutMs: 20_000,
@@ -705,6 +705,7 @@ export class OmpAgentSession implements AgentSession {
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
private outOfBandTurnStartAnnounced = false;
private activeTurnHasUserMessage = false;
private activeNoTurnPromptText: string | null = null;
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
@@ -849,6 +850,20 @@ export class OmpAgentSession implements AgentSession {
}
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
return this.startPrompt({ prompt, options });
}
private async startPrompt({
prompt,
options,
streamingBehavior,
announceTurn = false,
}: {
prompt: AgentPromptInput;
options?: AgentRunOptions;
streamingBehavior?: "steer" | "followUp";
announceTurn?: boolean;
}): Promise<StartTurnResult> {
if (this.activeTurnId) {
throw new Error("An OMP turn is already active");
}
@@ -861,17 +876,26 @@ export class OmpAgentSession implements AgentSession {
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.outOfBandTurnStartAnnounced = false;
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
this.clearNoTurnBuffers();
this.activeNoTurnPromptText = payload.text;
this.usagePoller.startTurn();
if (announceTurn) {
this.outOfBandTurnStartAnnounced = true;
this.emit({ type: "turn_started", provider: this.provider, turnId });
}
void (async () => {
try {
if (this.runtimeDead) await this.replaceRuntime(this.currentModeId ?? "full");
if (this.closed) throw new Error("OMP session is closed");
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
const ack = await this.runtimeSession.prompt({
message: payload.text,
images: payload.images,
streamingBehavior,
});
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
? this.pendingPromptResults.get(ack.requestId)
@@ -1191,6 +1215,7 @@ export class OmpAgentSession implements AgentSession {
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.outOfBandTurnStartAnnounced = false;
this.activeTurnHasUserMessage = false;
this.pendingClientMessages.length = 0;
this.clearNoTurnBuffers();
@@ -1235,12 +1260,13 @@ export class OmpAgentSession implements AgentSession {
if (!message) {
return null;
}
const streamingBehavior = commandName === "steer" ? "steer" : "followUp";
return {
run: async () => {
if (commandName === "steer") {
this.runtimeSession.steer(message);
if (!this.activeTurnId && !this.activeTurnStarted) {
await this.startPrompt({ prompt: message, streamingBehavior, announceTurn: true });
} else {
this.runtimeSession.followUp(message);
await this.runtimeSession.prompt({ message, streamingBehavior });
}
},
};
@@ -1804,11 +1830,11 @@ export class OmpAgentSession implements AgentSession {
case "turn_start":
this.activeTurnStarted = true;
this.clearNoTurnBuffers();
this.emit({
type: "turn_started",
provider: this.provider,
turnId,
});
if (this.outOfBandTurnStartAnnounced) {
this.outOfBandTurnStartAnnounced = false;
} else {
this.emit({ type: "turn_started", provider: this.provider, turnId });
}
return;
case "message_start":
this.handleMessageStart(event);
@@ -143,6 +143,26 @@ describe("OMP CLI runtime", () => {
await expect(result).rejects.toThrow("rejected");
await session.close();
});
test("sends the prompt streamingBehavior only when one is given", async () => {
const child = createOmpChild();
const session = await createRuntime(child, []).startSession({ cwd: "/workspace/project" });
for (const streamingBehavior of ["followUp", undefined] as const) {
const pending = capturePendingCommand(child, "prompt");
const result = session.prompt({ message: "after this, update the docs", streamingBehavior });
const command = await pending;
expect(withoutRequestId(command)).toEqual({
type: "prompt",
message: "after this, update the docs",
...(streamingBehavior ? { streamingBehavior } : {}),
});
child.stdout.write(
`${JSON.stringify({ id: command.id, type: "response", command: "prompt", success: true })}\n`,
);
await result;
}
await session.close();
});
test("uses the configured RPC timeout and attributes the pending phase", async () => {
vi.useFakeTimers();
const child = createOmpChild();
@@ -438,7 +458,7 @@ describe("OMP CLI runtime", () => {
replyToCommands(child, () => undefined);
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
await expect(session.prompt({ message: "hello" })).resolves.toEqual({ requestId: "req_1" });
});
test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => {
@@ -455,7 +475,7 @@ describe("OMP CLI runtime", () => {
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
await expect(session.prompt({ message: "hello" })).resolves.toEqual({ requestId: "req_1" });
expect(events).toContainEqual({
type: "prompt_result",
@@ -15,6 +15,7 @@ import {
type OmpRuntime,
type OmpRuntimeLaunch,
type OmpRuntimeSession,
type OmpPromptInput,
type OmpStartSessionInput,
} from "./runtime.js";
import {
@@ -142,14 +143,12 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
async prompt({ message, images, streamingBehavior }: OmpPromptInput): Promise<OmpPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
...(streamingBehavior ? { streamingBehavior: streamingBehavior } : {}),
});
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
return { requestId, ...ack };
@@ -282,13 +281,6 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
}
async handoff(customInstructions?: string): Promise<void> {
await this.request({
type: "handoff",
@@ -535,6 +535,7 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
type: z.literal("prompt"),
message: z.string(),
images: z.array(OmpImageContentSchema).optional(),
streamingBehavior: z.enum(["steer", "followUp"]).optional(),
}),
z.object({
...OmpCommandBase,
@@ -42,13 +42,18 @@ export interface OmpStartSessionInput {
extraArgs?: string[];
}
export interface OmpPromptInput {
message: string;
images?: Array<{ type: "image"; data: string; mimeType: string }>;
streamingBehavior?: "steer" | "followUp";
}
export interface OmpRuntimeSession {
readonly environment: Record<string, string>;
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck>;
// With streamingBehavior, a busy OMP queues the message instead of rejecting it, and an
// idle OMP starts a run with it.
prompt(input: OmpPromptInput): Promise<OmpPromptAck>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
@@ -71,10 +76,6 @@ export interface OmpRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void;
handoff(customInstructions?: string): Promise<void>;
respondToExtensionUiRequest(
id: string,
@@ -2,6 +2,7 @@ import type {
OmpRuntime,
OmpRuntimeLaunch,
OmpRuntimeSession,
OmpPromptInput,
OmpStartSessionInput,
} from "../runtime.js";
import type {
@@ -133,7 +134,11 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly environment: Record<string, string>;
fastModeResult = { enabled: false, active: false };
readonly setFastModeRequests: boolean[] = [];
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly prompts: Array<{
message: string;
imageCount: number;
streamingBehavior?: "steer" | "followUp";
}> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakeOmpSubagentSubscriptionLevel[] = [];
@@ -143,7 +148,6 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
steerError: Error | null = null;
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
readonly hostToolUpdates: OmpRpcHostToolUpdate[] = [];
@@ -216,14 +220,19 @@ export class FakeOmpSession implements OmpRuntimeSession {
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
if (this.state.isStreaming || this.state.isCompacting) {
async prompt({ message, images, streamingBehavior }: OmpPromptInput): Promise<OmpPromptAck> {
const busy = this.state.isStreaming || this.state.isCompacting;
if (busy && !streamingBehavior) {
throw new Error("Agent is already processing");
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.prompts.push({
message,
imageCount: images?.length ?? 0,
...(streamingBehavior ? { streamingBehavior: streamingBehavior } : {}),
});
if (busy) {
return {};
}
this.promptWaiters.shift()?.();
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
@@ -412,13 +421,6 @@ export class FakeOmpSession implements OmpRuntimeSession {
if (this.steerError) throw this.steerError;
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.followUpRequests.push({ message, imageCount: images?.length ?? 0 });
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.hostToolResults.push(result);
this.hostToolResultWaiters.shift()?.(result);
@@ -0,0 +1,60 @@
import { expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("changing thinking then mode preserves a foreground Claude command", async () => {
const logger = pino({ level: "silent" });
const cwd = await mkdtemp(path.join(tmpdir(), "claude-settings-mid-turn-"));
const daemon = await createTestPaseoDaemon({
logger,
agentClients: { claude: new ClaudeAgentClient({ logger }) },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
model: "claude-haiku-5-5",
modeId: "auto",
thinkingOptionId: "low",
cwd,
});
await client.subscribeAgentTimeline(agent.id, () => {}).ready;
await client.sendMessage(
agent.id,
"Run exactly these three Bash commands in the foreground, one per tool call, in order: python3 -c \"from pathlib import Path; import time; Path('started').write_text('started'); time.sleep(20); print(1)\", then the same with print(2), then print(3). Then reply DONE.",
);
// A tool-call row is emitted before Claude's permission check finishes.
// Wait for the command itself, so changing to acceptEdits cannot gate its start.
await expect.poll(() => existsSync(path.join(cwd, "started")), { timeout: 90_000 }).toBe(true);
const notice = await client.setAgentThinkingOption(agent.id, "medium");
expect(notice).toEqual({ type: "warning", message: "Thinking level applies next turn" });
await client.setAgentMode(agent.id, "acceptEdits");
await expect
.poll(
async () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
return timeline.entries.some(
({ item }) =>
item.type === "tool_call" &&
item.detail.type === "shell" &&
item.detail.command.includes("print(1)") &&
item.status === "completed",
);
},
{ timeout: 45_000, interval: 100 },
)
.toBe(true);
} finally {
await client.close();
await daemon.close();
await rm(cwd, { recursive: true, force: true });
}
}, 180_000);
@@ -5,6 +5,8 @@ import path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
@@ -131,6 +133,14 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim
return result.entries.map((entry) => entry.item);
}
async function readAssistantText(harness: Harness, agentId: string): Promise<string> {
const items = await timeline(harness.client, agentId);
return items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join("");
}
function compactionFiller(records = 1200): string {
const teas = ["assam", "darjeeling", "sencha", "oolong"];
return Array.from(
@@ -257,8 +267,80 @@ async function contextWindowUsedTokens(client: DaemonClient, agentId: string) {
describe("daemon E2E (real OMP)", () => {
test(
"a prompt OMP rejects before its agent runs fails the turn with OMP's error",
"an idle out-of-band follow-up starts a turn",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "idle-follow-up");
await harness.client.sendMessage(agent.id, "/follow-up Reply exactly IDLE_FOLLOWUP_OK");
await expect
.poll(() => readAssistantText(harness, agent.id), { timeout: 30_000 })
.toContain("IDLE_FOLLOWUP_OK");
const finish = await harness.client.waitForFinish(agent.id, TIMEOUT_MS);
expect(finish.status).toBe("idle");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"an agent-sent idle follow-up starts a turn and notifies its parent",
async () => {
const harness = await createHarness();
const mcp = new Client({ name: "idle-follow-up-test", version: "1.0.0" });
try {
const parent = await createAgent(harness, "follow-up-parent");
const child = await createAgent(harness, "follow-up-child");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(
`http://127.0.0.1:${harness.daemon.port}/mcp/agents?callerAgentId=${parent.id}`,
),
),
);
const response = await mcp.callTool({
name: "send_agent_prompt",
arguments: { agentId: child.id, prompt: "/follow-up Reply exactly MCP_IDLE_FOLLOWUP_OK" },
});
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
});
await expect
.poll(() => readAssistantText(harness, child.id), { timeout: 30_000 })
.toContain("MCP_IDLE_FOLLOWUP_OK");
await expect
.poll(
async () => {
const items = await timeline(harness.client, parent.id);
let notifications = 0;
for (const item of items) {
if (
item.type === "tool_call" &&
item.agentMessage?.sender.id === child.id &&
item.agentMessage.event === "finished"
)
notifications++;
}
return notifications;
},
{ timeout: 15_000 },
)
.toBe(1);
} finally {
await mcp.close();
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test.each(["Reply with ok.", "/follow-up Reply with ok."])(
"a rejected %s fails the turn with OMP's error",
async (prompt) => {
const harness = await createHarness({ ompHome: createOfflineOmpHome() });
try {
const agent = await harness.client.createAgent({
@@ -268,7 +350,7 @@ describe("daemon E2E (real OMP)", () => {
model: "anthropic/claude-haiku-4-5",
modeId: "full",
});
await harness.client.sendMessage(agent.id, "Reply with ok.");
await harness.client.sendMessage(agent.id, prompt);
const finish = await harness.client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect(finish.error).toContain("No API key found for anthropic");
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import {
MockLoadTestAgentClient,
MockLoadTestAgentSession,
} from "../agent/providers/mock-load-test-agent.js";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const firstResponse = Promise.withResolvers<void>();
class RetryQuestionSession extends MockLoadTestAgentSession {
private attempts = 0;
override async respondToPermission(
requestId: string,
response: Parameters<MockLoadTestAgentSession["respondToPermission"]>[1],
) {
this.attempts += 1;
process.send?.({ type: "attempt", requestId, response, sessionId: this.id });
if (this.attempts === 1) {
await firstResponse.promise;
throw new Error("Synthetic question response failure");
}
return super.respondToPermission(requestId, response);
}
}
class RetryQuestionClient extends MockLoadTestAgentClient {
override async createSession(config: Parameters<MockLoadTestAgentClient["createSession"]>[0]) {
const session = new RetryQuestionSession({ config, sessionId: randomUUID() });
session.subscribe((event) => {
if (event.type === "permission_resolved") {
process.send?.({
type: "resolved",
requestId: event.requestId,
response: event.resolution,
sessionId: session.id,
pendingRequestIds: session.getPendingPermissions().map((request) => request.id),
});
}
});
return session;
}
}
async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new RetryQuestionClient() },
isDev: true,
mcpEnabled: false,
corsAllowedOrigins: [`http://localhost:${process.env.E2E_METRO_PORT}`],
});
process.on("message", (message: unknown) => {
if (message === "release") firstResponse.resolve();
});
const shutdown = async () => {
firstResponse.resolve();
await daemon.close();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
process.once("disconnect", () => void shutdown());
process.send?.({ type: "ready", port: daemon.port, serverId: daemon.daemon.getServerId() });
}
void main().catch((error: unknown) => {
process.send?.({ type: "error", error: error instanceof Error ? error.message : String(error) });
process.exit(1);
});
+29 -1
View File
@@ -37,7 +37,7 @@ function jobBlocks(source) {
let currentJob;
for (const line of source.split("\n")) {
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line);
if (jobMatch) {
currentJob = jobMatch[1];
jobs.set(currentJob, []);
@@ -303,3 +303,31 @@ test("desktop packaging smokes main pushes and only the pull requests that touch
assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`));
}
});
test("stable iOS releases upload once and distribute to external testers before App Store review", () => {
const source = readFileSync(
new URL("packages/app/.eas/workflows/release-mobile.yml", repoRoot),
"utf8",
);
const jobs = jobBlocks(source);
const uploads = [...jobs.entries()].filter(
([, lines]) =>
lines.some((line) => /^ type: (submit|testflight)$/.test(line)) &&
lines.some((line) => line.includes("needs.build_ios.outputs.build_id")),
);
assert.equal(uploads.length, 1, "the iOS binary must be uploaded exactly once");
const [distributionId, distributionLines] = uploads[0];
const distribution = distributionLines.join("\n");
assert.match(distribution, /^ type: testflight$/m);
assert.match(distribution, /external_groups:\s*\n\s+- Paseo Beta/);
assert.match(distribution, /^ submit_beta_review: true$/m);
const review = jobs.get("submit_ios_for_review").join("\n");
assert.match(review, new RegExp(`needs: \\[build_ios, ${distributionId}\\]`));
assert.match(review, /APP_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_version \}\}"/);
assert.match(
review,
/APP_BUILD_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_build_version \}\}"/,
);
assert.match(review, /bundle exec fastlane ios submit_review/);
});
+1 -1
View File
@@ -21,7 +21,7 @@ For a local daemon, `project create` defaults to the current directory and resol
## Workspaces
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge` for a change request. Use `path` for a local directory or source checkout and `projectId` for the owning project; when no path is supplied, a worktree may resolve its source from `projectId`. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
`background: true` hides a workspace from default lists and the sidebar while its contents remain durable and accessible; omit `background` to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.