mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a2d1588d | ||
|
|
2325a2f32d | ||
|
|
12a33ea21a | ||
|
|
e1684aece0 | ||
|
|
c47e1f178b | ||
|
|
7c88a48be4 | ||
|
|
3196260bd4 | ||
|
|
870a44bc7f | ||
|
|
e8201ab66b |
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
|
||||
|
||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||
|
||||
## Publish
|
||||
|
||||
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||
|
||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(root, "../..");
|
||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||
const output = path.join(root, "output");
|
||||
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||
|
||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||
@@ -116,6 +117,7 @@ const STORES = {
|
||||
},
|
||||
google: {
|
||||
device: "iphone",
|
||||
androidScreenshots: "phoneScreenshots",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
textTop: 150,
|
||||
@@ -134,6 +136,7 @@ const STORES = {
|
||||
},
|
||||
"google-tablet": {
|
||||
device: "ipad",
|
||||
androidScreenshots: "tenInchScreenshots",
|
||||
width: 1800,
|
||||
height: 3200,
|
||||
textTop: 200,
|
||||
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
|
||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||
}
|
||||
|
||||
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||
// which holds links to these renders. Rewriting the links on every render keeps
|
||||
// added, removed and renamed panels in step with the App Store.
|
||||
async function linkAndroidScreenshots(folder, files) {
|
||||
const dir = path.join(androidImages, folder);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const [index, file] of files.entries()) {
|
||||
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPreview(rows) {
|
||||
const tile = 440;
|
||||
const gap = 40;
|
||||
@@ -387,7 +402,9 @@ try {
|
||||
const marks = await loadAgentMarks(work);
|
||||
const rows = [];
|
||||
for (const [name, store] of Object.entries(STORES)) {
|
||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||
const files = await renderStore(browser, name, store, font, marks, work);
|
||||
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||
rows.push(files);
|
||||
}
|
||||
await renderPreview(rows);
|
||||
} finally {
|
||||
|
||||
+4
-1
@@ -215,7 +215,8 @@ fastlane/metadata/android/
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
@@ -223,6 +224,8 @@ fastlane/metadata/android/
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
@@ -44,6 +44,15 @@ fall back to creating a fresh agent when continuation was requested.
|
||||
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
|
||||
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
|
||||
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
|
||||
|
||||
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
|
||||
Hub sends `toolPolicy.enforcement: "if-supported"` and
|
||||
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
|
||||
Without it, Hub preserves its existing strict tool-policy request. Available
|
||||
providers remain selectable; providers lacking strict approval support need an
|
||||
updated daemon to launch from Hub. Required external/plugin servers and explicitly
|
||||
required tool policies keep their existing refusal semantics.
|
||||
|
||||
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
|
||||
Provider controls remain provider-native; see [providers.md](providers.md).
|
||||
|
||||
|
||||
+10
-3
@@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
|
||||
(the default) fails closed unless the provider can approve exact injected server/tool
|
||||
identities without approving native tools. Native agent creation also accepts
|
||||
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
|
||||
unsupported providers retain ordinary MCP configuration and native permissions.
|
||||
Hub uses that optional request; MCP support and exact approval are not provider
|
||||
eligibility requirements. Optional injection is declared separately with
|
||||
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
|
||||
(including plugin-owned servers) keep their required semantics. The daemon
|
||||
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
|
||||
|
||||
## Core adapter patterns
|
||||
|
||||
|
||||
+13
-2
@@ -305,8 +305,8 @@ 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 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 (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||
- **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 uploads the store screenshots and 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.
|
||||
@@ -317,6 +317,16 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
||||
|
||||
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.
|
||||
|
||||
### Store screenshots
|
||||
|
||||
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||
|
||||
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
|
||||
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||
|
||||
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
@@ -359,6 +369,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
- `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
|
||||
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||
@@ -42,6 +42,20 @@ jobs:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
profile: production
|
||||
|
||||
upload_android_screenshots:
|
||||
name: Upload Google Play screenshots
|
||||
needs: [build_android, submit_android]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Upload screenshots
|
||||
run: |
|
||||
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||
bundle exec fastlane android upload_screenshots
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "sh.paseo"
|
||||
|
||||
# Store screenshots are rendered into assets/store-screenshots/output; see the
|
||||
# README there. Google Play and F-Droid both read fastlane/metadata/android at
|
||||
# the repo root, whose screenshot files link to those renders.
|
||||
REPO_ROOT = File.expand_path("../../..", __dir__)
|
||||
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
|
||||
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
|
||||
|
||||
# deliver reads screenshots from one folder per language and picks the device
|
||||
# slot from each image's size, so both Apple sets go into en-US together.
|
||||
def stage_app_store_screenshots
|
||||
root = Dir.mktmpdir("app-store-screenshots")
|
||||
locale = File.join(root, "en-US")
|
||||
FileUtils.mkdir_p(locale)
|
||||
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
|
||||
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
|
||||
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
|
||||
end
|
||||
end
|
||||
root
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Submit the latest TestFlight build to App Store Review"
|
||||
lane :submit_review do
|
||||
@@ -87,7 +111,8 @@ platform :ios do
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
screenshots_path: stage_app_store_screenshots,
|
||||
overwrite_screenshots: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
@@ -96,3 +121,25 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
platform :android do
|
||||
desc "Replace the Google Play store screenshots with the ones in the repo"
|
||||
lane :upload_screenshots do
|
||||
# supply attaches listing changes to a release on the production track.
|
||||
# The release workflow passes the version code it just submitted; without
|
||||
# it, supply uses the track's only release.
|
||||
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
|
||||
supply(
|
||||
package_name: APP_IDENTIFIER,
|
||||
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||
metadata_path: ANDROID_METADATA,
|
||||
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
sync_image_upload: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -509,10 +509,14 @@ export interface McpToolRef {
|
||||
}
|
||||
|
||||
export interface ToolPolicy {
|
||||
/** Missing or required fails closed; if-supported requests approval convenience only. */
|
||||
enforcement?: "required" | "if-supported";
|
||||
preapproved: McpToolRef[];
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
/** Per-server launch requirement; unspecified servers are required. */
|
||||
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
/**
|
||||
|
||||
@@ -571,3 +571,38 @@ test.each(["all", "base", "uncommitted"] as const)(
|
||||
expect(parsed.attachments).toEqual([attachment]);
|
||||
},
|
||||
);
|
||||
|
||||
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
|
||||
const toolPolicy = {
|
||||
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||
enforcement: "if-supported",
|
||||
};
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req",
|
||||
config: { provider: "pi", cwd: "/tmp", toolPolicy },
|
||||
});
|
||||
expect(parsed).toMatchObject({ config: { toolPolicy } });
|
||||
expect(
|
||||
SessionInboundMessageSchema.safeParse({
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "req",
|
||||
executionId: "exec",
|
||||
provider: "pi",
|
||||
cwd: "/tmp",
|
||||
prompt: "test",
|
||||
toolPolicy: { preapproved: toolPolicy.preapproved },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
SessionInboundMessageSchema.safeParse({
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "req",
|
||||
executionId: "exec",
|
||||
provider: "pi",
|
||||
cwd: "/tmp",
|
||||
prompt: "test",
|
||||
toolPolicy,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -490,8 +490,11 @@ const AgentSessionConfigSchema = z.object({
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.extend({
|
||||
enforcement: z.enum(["required", "if-supported"]).optional(),
|
||||
}).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -3671,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
optionalMcpSupport: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type AgentManagerEvent,
|
||||
type ManagedAgent,
|
||||
} from "./agent-manager.js";
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
@@ -445,7 +446,7 @@ class EnvProbeAgentClient extends TestAgentClient {
|
||||
}
|
||||
|
||||
class TestAgentSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
private runtimeModel: string | null = null;
|
||||
@@ -453,7 +454,9 @@ class TestAgentSession implements AgentSession {
|
||||
private turnIdCounter = 0;
|
||||
private interrupted = false;
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
constructor(private readonly config: AgentSessionConfig) {
|
||||
this.provider = config.provider;
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return {
|
||||
@@ -956,6 +959,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
|
||||
}
|
||||
});
|
||||
|
||||
test("steers a send that arrives while the previous turn is still starting", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class SlowStartSteeringSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
await startHeld.promise;
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new SlowStartSteeringSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "steered" });
|
||||
|
||||
expect(session.startPrompts).toEqual(["FIRST"]);
|
||||
expect(session.interruptCount).toBe(0);
|
||||
expect(
|
||||
manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.filter((row) => row.item.type === "user_message")
|
||||
.map((row) => ({
|
||||
text: row.item.type === "user_message" ? row.item.text : null,
|
||||
turnId: row.turnId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ text: "FIRST", turnId: "active-turn-1" },
|
||||
{ text: "SECOND", turnId: "active-turn-1" },
|
||||
]);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class FailingFirstStartSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "FIRST") {
|
||||
this.startPrompts.push(prompt);
|
||||
await startHeld.promise;
|
||||
throw new Error("first start failed");
|
||||
}
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new FailingFirstStartSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "turn_started" });
|
||||
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
|
||||
expect(session.interruptCount).toBe(0);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
@@ -3697,54 +3825,85 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
|
||||
expect(snapshot.config.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createAgent preserves a user-provided paseo MCP config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
test.each([
|
||||
{ provider: "pi", supportsMcp: true },
|
||||
{ provider: "pi", supportsMcp: false },
|
||||
{ provider: "codex", supportsMcp: false },
|
||||
])(
|
||||
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
|
||||
async ({ provider, supportsMcp }) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return new McpCapableTestAgentSession(config);
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
const client = new CaptureClient(provider);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
[provider]: client,
|
||||
},
|
||||
providerDefinitions: {
|
||||
[provider]: {
|
||||
enabled: true,
|
||||
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider,
|
||||
mcpServerRequirements: { paseo: "optional" },
|
||||
cwd: workdir,
|
||||
toolPolicy: {
|
||||
enforcement: "if-supported",
|
||||
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
|
||||
},
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
|
||||
paseo: "optional",
|
||||
});
|
||||
await manager.reloadAgentSession(snapshot.id);
|
||||
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
|
||||
paseo: "optional",
|
||||
});
|
||||
const expectedPolicy =
|
||||
provider === "codex"
|
||||
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
|
||||
: undefined;
|
||||
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
|
||||
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
},
|
||||
);
|
||||
|
||||
test("createAgent fails when cwd does not exist", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
@@ -12450,3 +12609,21 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("MCP requirements cannot mark a later plugin server optional", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (record.config.providerOptions != null) {
|
||||
config.providerOptions = record.config.providerOptions;
|
||||
}
|
||||
if (record.config.mcpServerRequirements != null)
|
||||
config.mcpServerRequirements = record.config.mcpServerRequirements;
|
||||
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
|
||||
if (record.config.systemPrompt != null) {
|
||||
config.systemPrompt = record.config.systemPrompt;
|
||||
@@ -1686,7 +1688,7 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
if (
|
||||
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
||||
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
|
||||
existing.session.capabilities.supportsMcpServers !== true
|
||||
) {
|
||||
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
||||
@@ -2734,7 +2736,7 @@ export class AgentManager {
|
||||
throw error;
|
||||
}
|
||||
if (isStaleProviderSessionError(error)) {
|
||||
pendingRun.start = { status: "failed", error: error.message };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
||||
agent.pendingReplacement = false;
|
||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
@@ -2742,20 +2744,24 @@ export class AgentManager {
|
||||
}
|
||||
agent.pendingReplacement = false;
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||
pendingRun.start = { status: "failed", error: errorMsg };
|
||||
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
||||
messageId: options.clientMessageId,
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
||||
try {
|
||||
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
||||
messageId: options.clientMessageId,
|
||||
});
|
||||
}
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
} finally {
|
||||
// A steer waiting on this start is released only once the run is cleared.
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
}
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2832,7 +2838,7 @@ export class AgentManager {
|
||||
agent.pendingReplacement = false;
|
||||
}
|
||||
const turnStartedAt = new Date();
|
||||
pendingRun.start = { status: "started", turnId };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.lifecycle = "running";
|
||||
@@ -3041,6 +3047,9 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentSteerOptions,
|
||||
): Promise<ActiveTurnSteerDispatchResult> {
|
||||
// A turn that is still starting has no id to steer yet. Replacing it would drop its
|
||||
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
|
||||
await this.runs.waitForForegroundStart(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
||||
if (!expectedTurnId) {
|
||||
@@ -3881,13 +3890,19 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
|
||||
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
|
||||
(name) => config.mcpServerRequirements?.[name] !== "optional",
|
||||
);
|
||||
}
|
||||
|
||||
private async requireExternalMcpSupport(
|
||||
session: AgentSession,
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
||||
!this.hasRequiredExternalMcpServers(launchConfig) ||
|
||||
session.capabilities.supportsMcpServers === true
|
||||
) {
|
||||
return;
|
||||
@@ -5450,6 +5465,7 @@ export class AgentManager {
|
||||
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
|
||||
const definition = this.providerDefinitions.get(config.provider);
|
||||
this.validateToolPolicyServers(config);
|
||||
this.validateMcpServerRequirements(config);
|
||||
if (config.toolPolicy && !definition?.applyToolPolicy) {
|
||||
throw new Error(
|
||||
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
|
||||
@@ -5460,6 +5476,16 @@ export class AgentManager {
|
||||
: config;
|
||||
}
|
||||
|
||||
private validateMcpServerRequirements(config: AgentSessionConfig): void {
|
||||
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
|
||||
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
|
||||
throw new Error(
|
||||
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateToolPolicyServers(config: AgentSessionConfig): void {
|
||||
if (!config.toolPolicy) return;
|
||||
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
|
||||
|
||||
@@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.systemPrompt) {
|
||||
serializable.systemPrompt = config.systemPrompt;
|
||||
}
|
||||
if (config.mcpServerRequirements) {
|
||||
serializable.mcpServerRequirements = config.mcpServerRequirements;
|
||||
}
|
||||
if (config.mcpServers) {
|
||||
serializable.mcpServers = config.mcpServers;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentRunState } from "./agent-run-state.js";
|
||||
|
||||
function track(promise: Promise<void>): { readonly resolved: boolean } {
|
||||
const state = { resolved: false };
|
||||
void (async () => {
|
||||
await promise;
|
||||
state.resolved = true;
|
||||
})();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe("waitForForegroundStart", () => {
|
||||
test("resolves once the starting turn has an id", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
|
||||
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("waits for a failed start's run to be cleared", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
expect(runs.hasPendingRun("agent")).toBe(true);
|
||||
|
||||
runs.settleForegroundRun("agent", run.token);
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves at once when no turn is starting", async () => {
|
||||
const runs = new AgentRunState();
|
||||
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
|
||||
resolveSettled: () => void;
|
||||
}
|
||||
|
||||
type ForegroundRunStart =
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
export interface PendingForegroundRun {
|
||||
token: string;
|
||||
kind: "foreground";
|
||||
stagedEvents: AgentStreamEvent[];
|
||||
start:
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
start: ForegroundRunStart;
|
||||
turnStartedPromise: Promise<void>;
|
||||
resolveTurnStarted: () => void;
|
||||
settled: boolean;
|
||||
settledPromise: Promise<void>;
|
||||
resolveSettled: () => void;
|
||||
@@ -54,6 +58,28 @@ export class AgentRunState {
|
||||
return run?.kind === "foreground" ? run : null;
|
||||
}
|
||||
|
||||
settleForegroundStart(
|
||||
run: PendingForegroundRun,
|
||||
start: Exclude<ForegroundRunStart, { status: "pending" }>,
|
||||
): void {
|
||||
run.start = start;
|
||||
if (start.status === "started") {
|
||||
run.resolveTurnStarted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
|
||||
* resolves only once its run is cleared, so callers never see a failed run still tracked.
|
||||
*/
|
||||
async waitForForegroundStart(agentId: string): Promise<void> {
|
||||
const run = this.getPendingRun(agentId);
|
||||
if (run?.start.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
await Promise.race([run.turnStartedPromise, run.settledPromise]);
|
||||
}
|
||||
|
||||
hasPendingRun(agentId: string): boolean {
|
||||
return this.getPendingRun(agentId) !== null;
|
||||
}
|
||||
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
|
||||
}
|
||||
|
||||
function createPendingForegroundRun(): PendingForegroundRun {
|
||||
let resolveTurnStarted!: () => void;
|
||||
const turnStartedPromise = new Promise<void>((resolvePromise) => {
|
||||
resolveTurnStarted = resolvePromise;
|
||||
});
|
||||
return {
|
||||
...createTrackedRunState(),
|
||||
kind: "foreground",
|
||||
start: { status: "pending" },
|
||||
turnStartedPromise,
|
||||
resolveTurnStarted,
|
||||
stagedEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ export interface AgentSessionConfig {
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/**
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
|
||||
@@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.nullable()
|
||||
.optional(),
|
||||
systemPrompt: z.string().nullable().optional(),
|
||||
mcpServerRequirements: z
|
||||
.record(z.string(), z.enum(["optional", "required"]))
|
||||
.nullable()
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
@@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick<
|
||||
| "toolPolicy"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
| "mcpServerRequirements"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
|
||||
@@ -135,6 +135,29 @@ describe("provider-owned option schemas", () => {
|
||||
});
|
||||
|
||||
describe("exact MCP preapproval mappings", () => {
|
||||
test("optional exact preapproval permits unsupported providers without changing their MCP config", () => {
|
||||
const registry = buildProviderRegistry(createTestLogger());
|
||||
const config = {
|
||||
provider: "pi",
|
||||
cwd: "/tmp",
|
||||
mcpServers: { hub: { type: "http" as const, url: "http://localhost/hub" } },
|
||||
};
|
||||
expect(
|
||||
registry.pi.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" }),
|
||||
).toEqual({ ...config, toolPolicy: undefined });
|
||||
});
|
||||
test.each(["claude", "codex", "opencode"])(
|
||||
"optional grants retain exact approval for %s",
|
||||
(provider) => {
|
||||
const registry = buildProviderRegistry(createTestLogger());
|
||||
const config = { provider, cwd: "/tmp" };
|
||||
expect(
|
||||
registry[provider]!.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" })
|
||||
.toolPolicy,
|
||||
).toEqual(hubPolicy);
|
||||
},
|
||||
);
|
||||
|
||||
test("unsupported providers fail closed with an unattended-execution error", () => {
|
||||
const registry = buildProviderRegistry(createTestLogger());
|
||||
expect(() =>
|
||||
|
||||
@@ -652,12 +652,15 @@ function createRegistryEntry(
|
||||
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
|
||||
applyToolPolicy: (config, toolPolicy) => {
|
||||
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
|
||||
throw new ToolPolicyUnsupportedError(provider);
|
||||
if (toolPolicy.enforcement !== "if-supported")
|
||||
throw new ToolPolicyUnsupportedError(provider);
|
||||
return { ...config, toolPolicy: undefined };
|
||||
}
|
||||
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
|
||||
return {
|
||||
...config,
|
||||
toolPolicy: toolPolicy
|
||||
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
|
||||
toolPolicy: exactPolicy
|
||||
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -30,16 +30,20 @@ function createQueryMock(events: unknown[]): Query {
|
||||
}
|
||||
|
||||
describe("Claude SDK env", () => {
|
||||
test.each(["node", "bun", process.execPath])(
|
||||
"identifies the spawned %s process as Paseo after clearing parent session env",
|
||||
async (command) => {
|
||||
test.each(
|
||||
["node", "bun", process.execPath].flatMap((command) =>
|
||||
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
|
||||
),
|
||||
)(
|
||||
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
|
||||
async ({ command, entrypoint }) => {
|
||||
let output = "";
|
||||
let exited = Promise.resolve<unknown[]>([]);
|
||||
const query = claudeQuery(
|
||||
{ prompt: "", options: {} },
|
||||
{
|
||||
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
||||
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
|
||||
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
|
||||
queryFactory: ({ options }) => {
|
||||
if (!options.spawnClaudeCodeProcess) {
|
||||
throw new Error("Missing Claude process launcher");
|
||||
@@ -65,7 +69,10 @@ describe("Claude SDK env", () => {
|
||||
|
||||
try {
|
||||
expect(await exited).toEqual([0, null]);
|
||||
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
...(entrypoint === undefined ? {} : { entrypoint }),
|
||||
parentSession: null,
|
||||
});
|
||||
} finally {
|
||||
query.close();
|
||||
}
|
||||
|
||||
@@ -73,8 +73,9 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
// Set our identity after sanitization clears the parent session's entrypoint.
|
||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
|
||||
// Session-open hooks may explicitly supply a client identity. Keep inherited
|
||||
// SDK/parent identities cleared while honoring this launch-only contribution.
|
||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
|
||||
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
||||
const selfNodeCommand = isDefaultRuntime
|
||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||
|
||||
@@ -226,6 +226,64 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe("Codex active-turn steering admission", () => {
|
||||
test("steers an accepted turn whose native id arrives after startTurn returns", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
try {
|
||||
const { turnId } = await session.startTurn("FIRST");
|
||||
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
|
||||
|
||||
await expect(steer).resolves.toEqual({ status: "accepted" });
|
||||
expect(
|
||||
appServer.requests().filter((request) => request.method === "turn/start"),
|
||||
).toHaveLength(1);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject(
|
||||
{
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "native-A",
|
||||
input: [{ type: "text", text: "SECOND" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("releases a waiting steer when the accepted turn ends before identification", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
try {
|
||||
const { turnId } = await session.startTurn("FIRST");
|
||||
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
|
||||
appServer.completeTurn();
|
||||
|
||||
await expect(steer).resolves.toEqual({ status: "unavailable" });
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a steer without the clearing contract leaves permissions open", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
|
||||
@@ -4456,9 +4456,18 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): Promise<SteerResult> {
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const nativeTurnId = this.currentTurnId;
|
||||
let nativeTurnId = this.currentTurnId;
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
|
||||
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const pendingIdentification = this.pendingForegroundTurnIdentification;
|
||||
// The start acknowledgement precedes the notification that identifies the native
|
||||
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
|
||||
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
|
||||
nativeTurnId = await pendingIdentification.promise;
|
||||
}
|
||||
if (!nativeTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"}
|
||||
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"}
|
||||
{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"}
|
||||
{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
|
||||
{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"}
|
||||
{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e91-73bd-7282-82c6-d3974711a941/context/fixture-marker-2026-10-21T13-05-57.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587957678}}
|
||||
{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}}
|
||||
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"provenance": {
|
||||
"package": "Zeno2019/pi-subagents-lean",
|
||||
"version": "2.9.2-lean.1",
|
||||
"sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620",
|
||||
"piVersion": "0.87.1",
|
||||
"captureDate": "2026-10-21"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"args": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json"
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
|
||||
"noSession": false,
|
||||
"status": "started",
|
||||
"mode": "background",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"autoExit": true
|
||||
}
|
||||
},
|
||||
"isError": false
|
||||
},
|
||||
{
|
||||
"type": "message_end",
|
||||
"message": {
|
||||
"role": "custom",
|
||||
"customType": "subagent_result",
|
||||
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
|
||||
"display": true,
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"mode": "background",
|
||||
"status": "completed",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 13,
|
||||
"contextTokens": 1775,
|
||||
"contextWindow": 372000,
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
|
||||
},
|
||||
"timestamp": 1792587964414
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "toolCall",
|
||||
"id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"name": "subagent",
|
||||
"arguments": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 2020,
|
||||
"output": 43,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 2063,
|
||||
"cost": {
|
||||
"input": 0.00808,
|
||||
"output": 0.0008600000000000001,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.00894
|
||||
}
|
||||
},
|
||||
"stopReason": "toolUse",
|
||||
"timestamp": 1792587953256,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "toolResult",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
|
||||
"noSession": false,
|
||||
"status": "started",
|
||||
"mode": "background",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"autoExit": true
|
||||
},
|
||||
"isError": false,
|
||||
"timestamp": 1792587957399
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "**Identifying need for await keyword**"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 2258,
|
||||
"output": 18,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 11,
|
||||
"totalTokens": 2276,
|
||||
"cost": {
|
||||
"input": 0.009032,
|
||||
"output": 0.00036,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.009392
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587957402,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "custom",
|
||||
"customType": "subagent_result",
|
||||
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
|
||||
"display": true,
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"mode": "background",
|
||||
"status": "completed",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 13,
|
||||
"contextTokens": 1775,
|
||||
"contextWindow": 372000,
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
|
||||
},
|
||||
"timestamp": 1792587964414
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 389,
|
||||
"output": 5,
|
||||
"cacheRead": 2176,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 2570,
|
||||
"cost": {
|
||||
"input": 0.0015559999999999999,
|
||||
"output": 0.0001,
|
||||
"cacheRead": 0.0008704000000000001,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.0025264
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587964417,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82",
|
||||
"rawStopReason": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"}
|
||||
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"}
|
||||
{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"}
|
||||
{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
|
||||
{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"}
|
||||
{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e90-d414-7243-a701-4eb4b0bec880/context/fixture-marker-2026-10-21T13-05-17.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587917968}}
|
||||
{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}}
|
||||
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"provenance": {
|
||||
"package": "edxeth/pi-subagents",
|
||||
"version": "2.9.2",
|
||||
"sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6",
|
||||
"piVersion": "0.87.1",
|
||||
"captureDate": "2026-10-21"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"args": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"id": "dd7f94e8",
|
||||
"name": "fixture-marker",
|
||||
"agent": "marker",
|
||||
"status": "completed",
|
||||
"mode": "background",
|
||||
"parentClosePolicy": "terminate",
|
||||
"deliveryState": "awaited",
|
||||
"async": false,
|
||||
"autoExit": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 49,
|
||||
"contextTokens": 1777,
|
||||
"contextWindow": 372000,
|
||||
"summary": "PASEO_EDXETH_UPSTREAM_OK",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
|
||||
}
|
||||
},
|
||||
"isError": false
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "toolCall",
|
||||
"id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"name": "subagent",
|
||||
"arguments": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 3256,
|
||||
"output": 43,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 3299,
|
||||
"cost": {
|
||||
"input": 0.013023999999999999,
|
||||
"output": 0.0008600000000000001,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.013883999999999999
|
||||
}
|
||||
},
|
||||
"stopReason": "toolUse",
|
||||
"timestamp": 1792587912428,
|
||||
"responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "toolResult",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"id": "dd7f94e8",
|
||||
"name": "fixture-marker",
|
||||
"agent": "marker",
|
||||
"status": "completed",
|
||||
"mode": "background",
|
||||
"parentClosePolicy": "terminate",
|
||||
"deliveryState": "awaited",
|
||||
"async": false,
|
||||
"autoExit": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 49,
|
||||
"contextTokens": 1777,
|
||||
"contextWindow": 372000,
|
||||
"summary": "PASEO_EDXETH_UPSTREAM_OK",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
|
||||
},
|
||||
"isError": false,
|
||||
"timestamp": 1792587924671
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 388,
|
||||
"output": 5,
|
||||
"cacheRead": 3072,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 3465,
|
||||
"cost": {
|
||||
"input": 0.001552,
|
||||
"output": 0.0001,
|
||||
"cacheRead": 0.0012288000000000002,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.0028808
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587924675,
|
||||
"responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0",
|
||||
"rawStopReason": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { edxethPiSubagents } from "./index.js";
|
||||
import { streamPiHistory } from "../../history-mapper.js";
|
||||
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
|
||||
|
||||
const args = {
|
||||
agent: "scout",
|
||||
name: "status-scout",
|
||||
title: "Check child status",
|
||||
task: "Return the completion marker",
|
||||
};
|
||||
|
||||
describe("edxeth/pi-subagents adapter", () => {
|
||||
test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => {
|
||||
const host = createPiExtensionHost();
|
||||
const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const };
|
||||
host.mapToolCall({
|
||||
...call,
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
const details = { id: "run-1", name: args.name, message: "Please clarify the task" };
|
||||
const result =
|
||||
delivery === "tool"
|
||||
? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } })
|
||||
: host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_ping",
|
||||
content: "Child requested help and exited",
|
||||
details,
|
||||
});
|
||||
expect(result?.subagents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
status: "canceled",
|
||||
subtitle: "Needs help",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves unrelated tools and other subagent protocols to their adapters", () => {
|
||||
const host = createPiExtensionHost(undefined, [edxethPiSubagents]);
|
||||
const call = { callId: "other", toolName: "subagent", args, status: "completed" as const };
|
||||
for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) {
|
||||
expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
host.mapToolCall({
|
||||
...call,
|
||||
toolName: "unrelated",
|
||||
result: { details: { id: "run-1", name: args.name, status: "completed" } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) {
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Unrecognized result",
|
||||
details,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("waits for cancellation after a kill acknowledgement reports stopping", () => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
const stopping = host.mapToolCall({
|
||||
callId: "kill-1",
|
||||
toolName: "subagent_kill",
|
||||
args: { id: "run-1" },
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "stopping" } },
|
||||
});
|
||||
expect(stopping?.subagents ?? []).toEqual([]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Cancelled",
|
||||
details: { id: "run-1", status: "cancelled" },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]);
|
||||
});
|
||||
|
||||
test("preserves a completion that arrives before its launch acknowledgement", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const early = host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Already done",
|
||||
details: { id: "fast-run", status: "completed" },
|
||||
});
|
||||
expect(early?.subagents ?? []).toEqual([]);
|
||||
const started = host.mapToolCall({
|
||||
callId: "fast-call",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" },
|
||||
},
|
||||
});
|
||||
expect(started?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "fast-call",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "fast-call",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ details: { status: "completed" }, expected: "completed" },
|
||||
{ details: { status: "failed" }, expected: "failed" },
|
||||
{ details: { status: "cancelled" }, expected: "canceled" },
|
||||
{ details: { error: "Child watcher exited unexpectedly" }, expected: "failed" },
|
||||
])("maps completion $details to $expected", ({ details, expected }) => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Child result",
|
||||
details: { id: "run-1", ...details },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]);
|
||||
});
|
||||
|
||||
test("a resume gets its own run and reads the retained child session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-"));
|
||||
const file = join(dir, "child.jsonl");
|
||||
const host = createPiExtensionHost();
|
||||
try {
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] },
|
||||
}) + "\n",
|
||||
);
|
||||
const original = host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "run-1", name: args.name, status: "completed", sessionFile: file },
|
||||
},
|
||||
});
|
||||
await original?.hydration;
|
||||
const resumed = host.mapToolCall({
|
||||
callId: "resume-1",
|
||||
toolName: "subagent_resume",
|
||||
args: { sessionFile: file, task: "Continue" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "run-2", name: args.name, status: "started", sessionFile: file },
|
||||
},
|
||||
});
|
||||
expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]);
|
||||
expect(await resumed?.hydration).toEqual([
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "pi",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "resume-1",
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Resumed run complete",
|
||||
details: { id: "run-2", status: "completed", sessionFile: file },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]);
|
||||
} finally {
|
||||
host.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("settles each batched child by run id without creating a parent-call card", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const result = host.mapToolCall({
|
||||
callId: "batch-1",
|
||||
toolName: "subagent",
|
||||
args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: {
|
||||
status: "batch",
|
||||
children: [
|
||||
{ id: "run-1", ...args, mode: "background", status: "completed" },
|
||||
{ id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "batch-1:0",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "batch-1",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "upsert",
|
||||
id: "batch-1:1",
|
||||
title: "reviewer",
|
||||
description: undefined,
|
||||
toolCallId: "batch-1",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Done",
|
||||
details: { id: "run-2", status: "completed" },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ mode: "foreground", statuses: ["running", "completed"] },
|
||||
{ mode: "background", statuses: ["running", "running", "completed"] },
|
||||
])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => {
|
||||
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
|
||||
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
|
||||
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
|
||||
});
|
||||
const events = await verifySubagentFixture(fixture);
|
||||
const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : []));
|
||||
expect(updates.map((event) => event.status)).toEqual(statuses);
|
||||
expect(new Set(updates.map((event) => event.id)).size).toBe(1);
|
||||
expect(events.some(({ event }) => event.type === "timeline")).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["foreground", "background"])(
|
||||
"restores a completed %s child and its transcript to the consumer store",
|
||||
async (mode) => {
|
||||
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
|
||||
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
|
||||
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
|
||||
});
|
||||
const store = new ProviderSubagentStore();
|
||||
for await (const event of streamPiHistory("pi", fixture.messages)) {
|
||||
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
|
||||
}
|
||||
const children = store.list("parent");
|
||||
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
|
||||
const transcript = store.fetchTimeline("parent", children[0]!.id);
|
||||
expect(transcript.rows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("settles a synchronous result on the card created when the tool started", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const call = { callId: "spawn-1", toolName: "subagent", args };
|
||||
const start = host.mapToolCall({ ...call, status: "running", result: null });
|
||||
expect(start?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "spawn-1",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
const end = host.mapToolCall({
|
||||
...call,
|
||||
status: "completed",
|
||||
result: {
|
||||
content: [{ type: "text", text: "DONE" }],
|
||||
details: {
|
||||
id: "run-1",
|
||||
name: args.name,
|
||||
agent: args.agent,
|
||||
mode: "background",
|
||||
status: "completed",
|
||||
deliveryState: "awaited",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(end?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "spawn-1",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
|
||||
import type { PiChildSessionFile, PiExtension } from "../contract.js";
|
||||
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
|
||||
|
||||
const Run = z
|
||||
.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
agent: z.string().trim().min(1).optional(),
|
||||
task: z.string().optional(),
|
||||
status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]),
|
||||
sessionFile: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const Args = z.object({ task: z.string().optional() }).passthrough();
|
||||
const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) });
|
||||
const Completion = z
|
||||
.object({
|
||||
id: z.string().trim().min(1),
|
||||
status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(),
|
||||
error: z.string().optional(),
|
||||
sessionFile: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const Ping = Completion.extend({ message: z.string() });
|
||||
|
||||
function childStatus(status: z.infer<typeof Run>["status"]) {
|
||||
if (status === "started") return "running";
|
||||
// A ping ends the child process and needs a new resume run. It is not success.
|
||||
if (status === "cancelled" || status === "pinged") return "canceled";
|
||||
return status;
|
||||
}
|
||||
|
||||
export const edxethPiSubagents: PiExtension = {
|
||||
id: "edxeth/pi-subagents",
|
||||
createSession: () => {
|
||||
const callsByRun = new Map<string, string>();
|
||||
const completions = new Map<string, z.infer<typeof Completion>>();
|
||||
const readSessions = new Map<string, string>();
|
||||
const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => {
|
||||
if (!file || readSessions.get(id) === file) return [];
|
||||
readSessions.set(id, file);
|
||||
return [{ id, file }];
|
||||
};
|
||||
const completeRun = (result: z.infer<typeof Completion>) => {
|
||||
const status = result.status ?? (result.error === undefined ? undefined : "failed");
|
||||
if (!status) return undefined;
|
||||
// A fast child can finish before the tool returns its run id. Keep the
|
||||
// terminal outcome so a later "started" acknowledgement cannot regress it.
|
||||
completions.set(result.id, { ...result, status });
|
||||
const id = callsByRun.get(result.id);
|
||||
if (!id) return undefined;
|
||||
const subagents: ProviderSubagentInputEvent[] = [
|
||||
{
|
||||
type: "upsert",
|
||||
id,
|
||||
status: childStatus(status),
|
||||
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
|
||||
},
|
||||
];
|
||||
return { subagents, childSessions: childSession(id, result.sessionFile) };
|
||||
};
|
||||
return {
|
||||
mapToolCall(call) {
|
||||
const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume";
|
||||
if (!isLaunch || call.status === "running") return undefined;
|
||||
const details = typeof call.result === "object" ? call.result?.details : null;
|
||||
const single = Run.safeParse(details);
|
||||
let runs: z.infer<typeof Run>[];
|
||||
if (single.success) {
|
||||
runs = [single.data];
|
||||
} else {
|
||||
const batch = Batch.safeParse(details);
|
||||
if (!batch.success) return undefined;
|
||||
runs = batch.data.children;
|
||||
}
|
||||
const args = Args.safeParse(call.args);
|
||||
const description = args.success ? args.data.task : undefined;
|
||||
const subagents: ProviderSubagentInputEvent[] = [];
|
||||
const childSessions: PiChildSessionFile[] = [];
|
||||
for (const [index, run] of runs.entries()) {
|
||||
const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`;
|
||||
const completion = completions.get(run.id);
|
||||
const status = completion?.status ?? run.status;
|
||||
callsByRun.set(run.id, id);
|
||||
subagents.push({
|
||||
type: "upsert",
|
||||
id,
|
||||
title: run.agent ?? run.name,
|
||||
description: run.task ?? description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus(status),
|
||||
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
|
||||
});
|
||||
childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile));
|
||||
}
|
||||
return {
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description,
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
},
|
||||
subagents,
|
||||
childSessions,
|
||||
};
|
||||
},
|
||||
mapCustomMessage(message) {
|
||||
if (message.customType === "subagent_ping") {
|
||||
const ping = Ping.safeParse(message.details);
|
||||
return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined;
|
||||
}
|
||||
if (message.customType !== "subagent_result") return undefined;
|
||||
const parsed = Completion.safeParse(message.details);
|
||||
return parsed.success ? completeRun(parsed.data) : undefined;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { piAskUser } from "./pi-ask-user/index.js";
|
||||
import { piSubagents } from "./pi-subagents/index.js";
|
||||
import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js";
|
||||
import { piMcpAdapter } from "./pi-mcp-adapter/index.js";
|
||||
import { rpivTodo } from "./rpiv-todo/index.js";
|
||||
import { piExampleTodo } from "./pi-example-todo/index.js";
|
||||
@@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
|
||||
|
||||
export const piExtensions = [
|
||||
piAskUser, // Dialog correlation
|
||||
edxethPiSubagents, // Structured results before the generic subagent tool matcher
|
||||
piSubagents, // Delegation calls
|
||||
tintinwebPiSubagents,
|
||||
gotgenesPiSubagents,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
|
||||
const BASE_CONFIG: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
@@ -49,3 +49,15 @@ describe("withRuntimePaseoMcpServer", () => {
|
||||
expect(result.mcpServers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("stripping an internal MCP server also strips its launch requirement", () => {
|
||||
const result = stripInternalPaseoMcpServer({
|
||||
...BASE_CONFIG,
|
||||
mcpServerRequirements: { paseo: "optional", hub: "optional" },
|
||||
mcpServers: {
|
||||
paseo: { type: "http", url: "http://127.0.0.1/mcp/agents?callerAgentId=agent-1" },
|
||||
hub: { type: "http", url: "https://hub.test/mcp" },
|
||||
},
|
||||
});
|
||||
expect(result.mcpServerRequirements).toEqual({ hub: "optional" });
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
|
||||
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
|
||||
|
||||
const next = { ...config };
|
||||
if (next.mcpServerRequirements?.[PASEO_MCP_SERVER_NAME]) {
|
||||
next.mcpServerRequirements = { ...next.mcpServerRequirements };
|
||||
delete next.mcpServerRequirements[PASEO_MCP_SERVER_NAME];
|
||||
if (Object.keys(next.mcpServerRequirements).length === 0) delete next.mcpServerRequirements;
|
||||
}
|
||||
if (Object.keys(nextMcpServers).length > 0) {
|
||||
next.mcpServers = nextMcpServers;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { canRunRealProvider } from "./real-provider-test-config.js";
|
||||
|
||||
test("keeps two concurrent startup steer messages in one real Codex turn", async (context) => {
|
||||
if (!(await canRunRealProvider("codex"))) {
|
||||
context.skip();
|
||||
return;
|
||||
}
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "codex-steer-starting-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const collector = createMessageCollector(client);
|
||||
const subscriptions: ReturnType<DaemonClient["subscribeAgentTimeline"]>[] = [];
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
});
|
||||
const subscription = client.subscribeAgentTimeline(agent.id, () => {});
|
||||
subscriptions.push(subscription);
|
||||
await subscription.ready;
|
||||
await Promise.all([
|
||||
client.sendAgentMessage(agent.id, "Reply exactly: FIRST", {
|
||||
messageId: `first-${attempt}`,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
client.sendAgentMessage(agent.id, "Reply exactly: SECOND", {
|
||||
messageId: `second-${attempt}`,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
]);
|
||||
await client.waitForFinish(agent.id, 90_000);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
|
||||
const users = timeline.entries.filter((entry) => entry.item.type === "user_message");
|
||||
const events = collector.messages.flatMap((message) =>
|
||||
message.type === "agent_stream" && message.payload.agentId === agent.id
|
||||
? [message.payload.event]
|
||||
: [],
|
||||
);
|
||||
expect(users.map((entry) => entry.item.text).sort()).toEqual([
|
||||
"Reply exactly: FIRST",
|
||||
"Reply exactly: SECOND",
|
||||
]);
|
||||
expect(new Set(users.map((entry) => entry.turnId)).size).toBe(1);
|
||||
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1);
|
||||
expect(
|
||||
events.filter((event) => event.type === "turn_canceled" || event.type === "turn_failed"),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
timeline.entries.some(
|
||||
(entry) =>
|
||||
entry.item.type === "assistant_message" && entry.item.text.includes("[System Error]"),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(subscriptions.map((subscription) => subscription.release()));
|
||||
collector.unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
@@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works
|
||||
}),
|
||||
).toMatchObject({ payload: { agentId, accepted: true } });
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
test("Hub completes the standard hello before rejecting a second hello", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
@@ -214,3 +214,14 @@ describe("persistence hooks", () => {
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("restores optional MCP requirements for continuation", () => {
|
||||
const record = createRecord({
|
||||
config: {
|
||||
mcpServerRequirements: { hub: "optional" },
|
||||
mcpServers: { hub: { type: "http", url: "https://hub.test/mcp" } },
|
||||
},
|
||||
});
|
||||
expect(buildConfigOverrides(record).mcpServerRequirements).toEqual({ hub: "optional" });
|
||||
expect(buildSessionConfig(record)?.mcpServerRequirements).toEqual({ hub: "optional" });
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
toolPolicy: record.config?.toolPolicy ?? undefined,
|
||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||
mcpServers: record.config?.mcpServers ?? undefined,
|
||||
mcpServerRequirements: record.config?.mcpServerRequirements ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +101,7 @@ export function buildSessionConfig(
|
||||
toolPolicy: overrides.toolPolicy,
|
||||
systemPrompt: overrides.systemPrompt,
|
||||
mcpServers: overrides.mcpServers,
|
||||
mcpServerRequirements: overrides.mcpServerRequirements,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||
import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
@@ -72,3 +72,71 @@ test("the configuration example saves Codex options and injects launch-only MCP
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("session-open entrypoint contributions are optional and rederived for existing agents", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-"));
|
||||
const launches: { provider: string; env?: Record<string, string> }[] = [];
|
||||
function recordingProvider(provider: "claude" | "codex"): AgentClient {
|
||||
const inner = createTestAgentClient(provider);
|
||||
return {
|
||||
provider: inner.provider,
|
||||
capabilities: inner.capabilities,
|
||||
isAvailable: () => inner.isAvailable(),
|
||||
fetchCatalog: (options) => inner.fetchCatalog(options),
|
||||
createSession: async (...args: Parameters<typeof inner.createSession>) => {
|
||||
launches.push({ provider, env: args[1]?.env });
|
||||
return inner.createSession(...args);
|
||||
},
|
||||
resumeSession: async (...args: Parameters<typeof inner.resumeSession>) => {
|
||||
launches.push({ provider, env: args[2]?.env });
|
||||
return inner.resumeSession(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.14.0-beta.2",
|
||||
agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") },
|
||||
mcpEnabled: false,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.14.0-beta.2",
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "claude") return;
|
||||
return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } };
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const existing = await client.createAgent({ provider: "claude", cwd: directory });
|
||||
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
|
||||
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
|
||||
await client.createAgent({ provider: "claude", cwd: directory });
|
||||
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
|
||||
await client.createAgent({ provider: "codex", cwd: directory });
|
||||
expect(launches.at(-1)).toMatchObject({ provider: "codex" });
|
||||
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
await client.disablePlugin("entrypoint-test");
|
||||
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
|
||||
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceSortTimes: true,
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
optionalMcpSupport: true,
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: true,
|
||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
||||
|
||||
Reference in New Issue
Block a user